triannon 0.0.3 → 0.0.4
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.
- checksums.yaml +4 -4
- data/README.md +11 -1
- data/Rakefile +18 -6
- data/app/controllers/triannon/annotations_controller.rb +37 -0
- data/app/models/triannon/annotation.rb +115 -60
- data/app/models/triannon/annotation_ldp.rb +54 -0
- data/app/services/triannon/ldp_creator.rb +265 -0
- data/app/services/triannon/ldp_destroyer.rb +15 -0
- data/app/services/triannon/ldp_loader.rb +82 -0
- data/app/services/triannon/ldp_to_oa_mapper.rb +61 -0
- data/app/services/triannon/root_annotation_creator.rb +36 -0
- data/app/views/layouts/triannon/application.html.erb +2 -2
- data/app/views/triannon/annotations/_form.html.erb +1 -1
- data/app/views/triannon/annotations/show.html.erb +7 -7
- data/config/initializers/mime_types.rb +4 -0
- data/config/initializers/root_annotation_container.rb +8 -0
- data/config/triannon.yml +6 -0
- data/lib/generators/triannon/install_generator.rb +15 -0
- data/lib/rdf/triannon_vocab.rb +14 -0
- data/lib/triannon.rb +45 -1
- data/lib/triannon/oa_context_20130208.json +53 -0
- data/lib/triannon/version.rb +1 -1
- metadata +72 -7
- data/app/models/triannon/graph_validator.rb +0 -33
- data/db/migrate/20140912173046_create_triannon_annotations.rb +0 -9
@@ -0,0 +1,265 @@
|
|
1
|
+
|
2
|
+
module Triannon
|
3
|
+
|
4
|
+
# creates a new Annotation in the LDP server
|
5
|
+
class LdpCreator
|
6
|
+
|
7
|
+
# use LDP protocol to create the OpenAnnotation.Annotation in an RDF store
|
8
|
+
# @param [Triannon::Annotation] anno a Triannon::Annotation object, from which we use the graph
|
9
|
+
def self.create(anno)
|
10
|
+
# TODO: we should not get here if the Annotation object already has an id
|
11
|
+
result = Triannon::LdpCreator.new anno
|
12
|
+
result.create_base
|
13
|
+
|
14
|
+
bodies_solns = anno.graph.query([nil, RDF::OpenAnnotation.hasBody, nil])
|
15
|
+
if bodies_solns.size > 0
|
16
|
+
result.create_body_container
|
17
|
+
result.create_body_resources
|
18
|
+
end
|
19
|
+
|
20
|
+
targets_solns = anno.graph.query([nil, RDF::OpenAnnotation.hasTarget, nil])
|
21
|
+
# NOTE: Annotation is invalid if there are no target statements
|
22
|
+
if targets_solns.size > 0
|
23
|
+
result.create_target_container
|
24
|
+
result.create_target_resources
|
25
|
+
end
|
26
|
+
|
27
|
+
result.id
|
28
|
+
end
|
29
|
+
|
30
|
+
# given an RDF::Resource (an RDF::Node or RDF::URI), look for all the statements with that object
|
31
|
+
# as the subject, and recurse through the graph to find all descendant statements pertaining to the subject
|
32
|
+
# @param subject the RDF object to be used as the subject in the graph query. Should be an RDF::Node or RDF::URI
|
33
|
+
# @param [RDF::Graph] graph
|
34
|
+
# @return [Array[RDF::Statement]] all the triples with the given subject
|
35
|
+
def self.subject_statements(subject, graph)
|
36
|
+
result = []
|
37
|
+
graph.query([subject, nil, nil]).each { |stmt|
|
38
|
+
result << stmt
|
39
|
+
subject_statements(stmt.object, graph).each { |s| result << s }
|
40
|
+
}
|
41
|
+
result.uniq
|
42
|
+
end
|
43
|
+
|
44
|
+
attr_accessor :id
|
45
|
+
|
46
|
+
# @param [Triannon::Annotation] anno a Triannon::Annotation object
|
47
|
+
def initialize(anno)
|
48
|
+
@anno = anno
|
49
|
+
@base_uri = Triannon.config[:ldp_url]
|
50
|
+
end
|
51
|
+
|
52
|
+
# POSTS a ttl representation of the LDP Annotation container to the LDP store
|
53
|
+
def create_base
|
54
|
+
# TODO: we should error if the Annotation object already has an id
|
55
|
+
|
56
|
+
g = RDF::Graph.new
|
57
|
+
@anno.graph.each { |s|
|
58
|
+
g << s
|
59
|
+
}
|
60
|
+
|
61
|
+
# remove the hasBody statements and any other statements associated with them
|
62
|
+
bodies_stmts = g.query([nil, RDF::OpenAnnotation.hasBody, nil])
|
63
|
+
bodies_stmts.each { |has_body_stmt |
|
64
|
+
g.delete has_body_stmt
|
65
|
+
body_obj = has_body_stmt.object
|
66
|
+
Triannon::LdpCreator.subject_statements(body_obj, g).each { |s|
|
67
|
+
g.delete s
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
# remove the hasTarget statements and any other statements associated with them
|
72
|
+
targets_stmts = g.query([nil, RDF::OpenAnnotation.hasTarget, nil])
|
73
|
+
targets_stmts.each { |has_target_stmt |
|
74
|
+
g.delete has_target_stmt
|
75
|
+
target_obj = has_target_stmt.object
|
76
|
+
Triannon::LdpCreator.subject_statements(target_obj, g).each { |s|
|
77
|
+
g.delete s
|
78
|
+
}
|
79
|
+
}
|
80
|
+
|
81
|
+
# transform an outer blank node into a null relative URI
|
82
|
+
anno_stmts = g.query([nil, RDF.type, RDF::OpenAnnotation.Annotation])
|
83
|
+
anno_rdf_obj = anno_stmts.first.subject
|
84
|
+
if anno_rdf_obj.is_a?(RDF::Node)
|
85
|
+
# we need to use the null relative URI representation of blank nodes to write to LDP
|
86
|
+
anno_subject = RDF::URI.new
|
87
|
+
else # it's already a URI
|
88
|
+
anno_subject = anno_rdf_obj
|
89
|
+
end
|
90
|
+
Triannon::LdpCreator.subject_statements(anno_rdf_obj, g).each { |s|
|
91
|
+
if s.subject == anno_rdf_obj && anno_subject != anno_rdf_obj
|
92
|
+
g << RDF::Statement({:subject => anno_subject,
|
93
|
+
:predicate => s.predicate,
|
94
|
+
:object => s.object})
|
95
|
+
g.delete s
|
96
|
+
end
|
97
|
+
}
|
98
|
+
|
99
|
+
@id = create_resource g.to_ttl
|
100
|
+
end
|
101
|
+
|
102
|
+
# creates the LDP container for any and all bodies for this annotation
|
103
|
+
def create_body_container
|
104
|
+
create_direct_container RDF::OpenAnnotation.hasBody
|
105
|
+
end
|
106
|
+
|
107
|
+
# creates the LDP container for any and all targets for this annotation
|
108
|
+
def create_target_container
|
109
|
+
create_direct_container RDF::OpenAnnotation.hasTarget
|
110
|
+
end
|
111
|
+
|
112
|
+
# create the body resources inside the (already created) body container
|
113
|
+
def create_body_resources
|
114
|
+
bodies_solns = @anno.graph.query([nil, RDF::OpenAnnotation.hasBody, nil])
|
115
|
+
body_ids = []
|
116
|
+
bodies_solns.each { |has_body_stmt |
|
117
|
+
graph_for_resource = RDF::Graph.new
|
118
|
+
body_obj = has_body_stmt.object
|
119
|
+
if body_obj.is_a?(RDF::Node)
|
120
|
+
# we need to use the null relative URI representation of blank nodes to write to LDP
|
121
|
+
body_subject = RDF::URI.new
|
122
|
+
else
|
123
|
+
# it's already a URI, but we need to use the null relative URI representation so we can
|
124
|
+
# write out as a Triannon:externalRef property with the URL, and any addl props too.
|
125
|
+
if body_obj.to_str
|
126
|
+
body_subject = RDF::URI.new
|
127
|
+
graph_for_resource << RDF::Statement({:subject => body_subject,
|
128
|
+
:predicate => RDF::Triannon.externalReference,
|
129
|
+
:object => RDF::URI.new(body_obj.to_str)})
|
130
|
+
addl_stmts = @anno.graph.query([body_obj, nil, nil])
|
131
|
+
addl_stmts.each { |s|
|
132
|
+
graph_for_resource << RDF::Statement({:subject => body_subject,
|
133
|
+
:predicate => s.predicate,
|
134
|
+
:object => s.object})
|
135
|
+
}
|
136
|
+
else # it's already a null relative URI
|
137
|
+
body_subject = body_obj
|
138
|
+
end
|
139
|
+
end
|
140
|
+
# add statements with body_obj as the subject
|
141
|
+
Triannon::LdpCreator.subject_statements(body_obj, @anno.graph).each { |s|
|
142
|
+
if s.subject == body_obj
|
143
|
+
graph_for_resource << RDF::Statement({:subject => body_subject,
|
144
|
+
:predicate => s.predicate,
|
145
|
+
:object => s.object})
|
146
|
+
else
|
147
|
+
graph_for_resource << s
|
148
|
+
end
|
149
|
+
}
|
150
|
+
body_ids << create_resource(graph_for_resource.to_ttl, "#{@id}/b")
|
151
|
+
}
|
152
|
+
body_ids
|
153
|
+
end
|
154
|
+
|
155
|
+
# create the target resources inside the (already created) target container
|
156
|
+
def create_target_resources
|
157
|
+
target_solns = @anno.graph.query([nil, RDF::OpenAnnotation.hasTarget, nil])
|
158
|
+
target_ids = []
|
159
|
+
target_solns.each { |has_target_stmt |
|
160
|
+
graph_for_resource = RDF::Graph.new
|
161
|
+
target_obj = has_target_stmt.object
|
162
|
+
if target_obj.is_a?(RDF::Node)
|
163
|
+
# we need to use the null relative URI representation of blank nodes to write to LDP
|
164
|
+
target_subject = RDF::URI.new
|
165
|
+
else
|
166
|
+
# it's already a URI, but we need to use the null relative URI representation so we can
|
167
|
+
# write out as a Triannon:externalRef property with the URL, and any addl props too.
|
168
|
+
if target_obj.to_str
|
169
|
+
target_subject = RDF::URI.new
|
170
|
+
graph_for_resource << RDF::Statement({:subject => target_subject,
|
171
|
+
:predicate => RDF::Triannon.externalReference,
|
172
|
+
:object => RDF::URI.new(target_obj.to_str)})
|
173
|
+
addl_stmts = @anno.graph.query([target_obj, nil, nil])
|
174
|
+
addl_stmts.each { |s|
|
175
|
+
graph_for_resource << RDF::Statement({:subject => target_subject,
|
176
|
+
:predicate => s.predicate,
|
177
|
+
:object => s.object})
|
178
|
+
}
|
179
|
+
else # it's already a null relative URI
|
180
|
+
target_subject = target_obj
|
181
|
+
end
|
182
|
+
end
|
183
|
+
# add statements with target_obj as the subject
|
184
|
+
orig_source_objects = [] # the object URI nodes from targetObject, OA.hasSource, (uri) statements
|
185
|
+
Triannon::LdpCreator.subject_statements(target_obj, @anno.graph).each { |s|
|
186
|
+
if s.subject == target_obj
|
187
|
+
# deal with external references in hasSource statements (i.e. targetObject, OA.hasSource, (url) )
|
188
|
+
if s.predicate == RDF::OpenAnnotation.hasSource && s.object.is_a?(RDF::URI) && s.object.to_str
|
189
|
+
# we need to represent the source URL as an externalReference - use a hash URI
|
190
|
+
source_object = RDF::URI.new("#source")
|
191
|
+
orig_source_objects << s.object
|
192
|
+
graph_for_resource << RDF::Statement({:subject => source_object,
|
193
|
+
:predicate => RDF::Triannon.externalReference,
|
194
|
+
:object => RDF::URI.new(s.object.to_str)})
|
195
|
+
# and all of the source URL's addl props
|
196
|
+
Triannon::LdpCreator.subject_statements(s.object, @anno.graph).each { |ss|
|
197
|
+
if ss.subject == s.object
|
198
|
+
graph_for_resource << RDF::Statement({:subject => source_object,
|
199
|
+
:predicate => ss.predicate,
|
200
|
+
:object => ss.object})
|
201
|
+
else
|
202
|
+
graph_for_resource << ss
|
203
|
+
end
|
204
|
+
}
|
205
|
+
# add the targetObj, OA.hasSource, (hash URI representation) to graph
|
206
|
+
graph_for_resource << RDF::Statement({:subject => target_subject,
|
207
|
+
:predicate => s.predicate,
|
208
|
+
:object => source_object})
|
209
|
+
else
|
210
|
+
graph_for_resource << RDF::Statement({:subject => target_subject,
|
211
|
+
:predicate => s.predicate,
|
212
|
+
:object => s.object})
|
213
|
+
end
|
214
|
+
else
|
215
|
+
graph_for_resource << s
|
216
|
+
end
|
217
|
+
}
|
218
|
+
# make sure the graph we will write contains no extraneous statements about source URI
|
219
|
+
# now represented as hash URI (#source)
|
220
|
+
orig_source_objects.each { |rdf_uri_node|
|
221
|
+
Triannon::LdpCreator.subject_statements(rdf_uri_node, graph_for_resource).each { |s|
|
222
|
+
graph_for_resource.delete(s)
|
223
|
+
}
|
224
|
+
}
|
225
|
+
target_ids << create_resource(graph_for_resource.to_ttl, "#{@id}/t")
|
226
|
+
}
|
227
|
+
target_ids
|
228
|
+
end
|
229
|
+
|
230
|
+
def conn
|
231
|
+
@c ||= Faraday.new @base_uri
|
232
|
+
end
|
233
|
+
|
234
|
+
protected
|
235
|
+
def create_resource body, url = nil
|
236
|
+
response = conn.post do |req|
|
237
|
+
req.url url if url
|
238
|
+
req.headers['Content-Type'] = 'application/x-turtle'
|
239
|
+
req.body = body
|
240
|
+
end
|
241
|
+
new_url = response.headers['Location'] ? response.headers['Location'] : response.headers['location']
|
242
|
+
new_url.split('/').last if new_url
|
243
|
+
end
|
244
|
+
|
245
|
+
# Creates an empty LDP DirectContainer in LDP Storage that is a member of the base container and has the memberRelation per the oa_vocab_term
|
246
|
+
# The id of the created containter will be (base container id)b if hasBody or (base container id)/t if hasTarget
|
247
|
+
# @param [RDF::Vocabulary::Term] oa_vocab_term RDF::OpenAnnotation.hasTarget or RDF::OpenAnnotation.hasBody
|
248
|
+
def create_direct_container oa_vocab_term
|
249
|
+
null_rel_uri = RDF::URI.new
|
250
|
+
g = RDF::Graph.new
|
251
|
+
g << [null_rel_uri, RDF.type, RDF::LDP.DirectContainer]
|
252
|
+
g << [null_rel_uri, RDF::LDP.hasMemberRelation, oa_vocab_term]
|
253
|
+
g << [null_rel_uri, RDF::LDP.membershipResource, RDF::URI.new("#{@base_uri}/#{id}")]
|
254
|
+
|
255
|
+
response = conn.post do |req|
|
256
|
+
req.url "#{id}"
|
257
|
+
req.headers['Content-Type'] = 'application/x-turtle'
|
258
|
+
# OA vocab relationships all of form "hasXXX"
|
259
|
+
req.headers['Slug'] = oa_vocab_term.fragment.slice(3).downcase
|
260
|
+
req.body = g.to_ttl
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
end
|
265
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
module Triannon
|
3
|
+
|
4
|
+
class LdpDestroyer
|
5
|
+
|
6
|
+
def self.destroy key
|
7
|
+
conn = Faraday.new Triannon.config[:ldp_url]
|
8
|
+
|
9
|
+
resp = conn.delete { |req| req.url key }
|
10
|
+
if resp.status != 204
|
11
|
+
raise "Unable to delete Annotation: #{key}\nResponse Status: #{resp.status}\nResponse Body: #{resp.body}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
|
2
|
+
module Triannon
|
3
|
+
|
4
|
+
# Loads an existing Annotation from the LDP server
|
5
|
+
class LdpLoader
|
6
|
+
|
7
|
+
def self.load key
|
8
|
+
l = Triannon::LdpLoader.new key
|
9
|
+
l.load_annotation
|
10
|
+
l.load_body
|
11
|
+
l.load_target
|
12
|
+
|
13
|
+
oa_graph = Triannon::LdpToOaMapper.ldp_to_oa l.annotation
|
14
|
+
oa_graph
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.find_all
|
18
|
+
l = Triannon::LdpLoader.new
|
19
|
+
l.find_all
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_accessor :annotation
|
23
|
+
|
24
|
+
def initialize key = nil
|
25
|
+
@key = key
|
26
|
+
@base_uri = Triannon.config[:ldp_url]
|
27
|
+
@annotation = Triannon::AnnotationLdp.new
|
28
|
+
end
|
29
|
+
|
30
|
+
def load_annotation
|
31
|
+
@annotation.load_data_into_graph get_ttl @key
|
32
|
+
end
|
33
|
+
|
34
|
+
def load_body
|
35
|
+
@annotation.body_uris.each { |body_uri|
|
36
|
+
sub_path = body_uri.to_s.split(@base_uri + '/').last
|
37
|
+
@annotation.load_data_into_graph get_ttl sub_path
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def load_target
|
42
|
+
@annotation.target_uris.each { |target_uri|
|
43
|
+
sub_path = target_uri.to_s.split(@base_uri + '/').last
|
44
|
+
@annotation.load_data_into_graph get_ttl sub_path
|
45
|
+
}
|
46
|
+
end
|
47
|
+
|
48
|
+
# @return [Array<Triannon::Annotation>] an array of Triannon::Annotation objects with just the id set. Enough info to build the index page
|
49
|
+
def find_all
|
50
|
+
root_ttl = get_ttl
|
51
|
+
objs = []
|
52
|
+
|
53
|
+
g = RDF::Graph.new
|
54
|
+
g.from_ttl root_ttl
|
55
|
+
root_uri = RDF::URI.new @base_uri
|
56
|
+
results = g.query [root_uri, RDF::LDP.contains, nil]
|
57
|
+
results.each do |stmt|
|
58
|
+
id = stmt.object.to_s.split('/').last
|
59
|
+
objs << Triannon::Annotation.new(:id => id)
|
60
|
+
end
|
61
|
+
|
62
|
+
objs
|
63
|
+
end
|
64
|
+
|
65
|
+
protected
|
66
|
+
|
67
|
+
def get_ttl sub_path = nil
|
68
|
+
resp = conn.get do |req|
|
69
|
+
req.url "#{sub_path}" if sub_path
|
70
|
+
req.headers['Accept'] = 'text/turtle'
|
71
|
+
end
|
72
|
+
resp.body
|
73
|
+
end
|
74
|
+
|
75
|
+
def conn
|
76
|
+
@c ||= Faraday.new @base_uri
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Triannon
|
2
|
+
class LdpToOaMapper
|
3
|
+
|
4
|
+
# maps an AnnotationLdp to an OA RDF::Graph
|
5
|
+
def self.ldp_to_oa ldp_anno
|
6
|
+
mapper = Triannon::LdpToOaMapper.new ldp_anno
|
7
|
+
mapper.extract_base
|
8
|
+
mapper.extract_body
|
9
|
+
mapper.extract_target
|
10
|
+
mapper.oa_graph
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_accessor :id, :oa_graph
|
14
|
+
|
15
|
+
def initialize ldp_anno
|
16
|
+
@ldp = ldp_anno
|
17
|
+
@oa_graph = RDF::Graph.new
|
18
|
+
end
|
19
|
+
|
20
|
+
def extract_base
|
21
|
+
@ldp.stripped_graph.each_statement do |stmnt|
|
22
|
+
if stmnt.predicate == RDF.type && stmnt.object == RDF::OpenAnnotation.Annotation
|
23
|
+
@id = stmnt.subject.to_s.split('/').last
|
24
|
+
@root_uri = RDF::URI.new(Triannon.config[:triannon_base_url] + "/#{@id}")
|
25
|
+
@oa_graph << [@root_uri, RDF.type, RDF::OpenAnnotation.Annotation]
|
26
|
+
|
27
|
+
elsif stmnt.predicate == RDF::OpenAnnotation.motivatedBy
|
28
|
+
@oa_graph << [@root_uri, stmnt.predicate, stmnt.object]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def extract_body
|
34
|
+
@ldp.body_uris.each { |body_uri|
|
35
|
+
res = @ldp.stripped_graph.query [body_uri, RDF.type, RDF::Content.ContentAsText]
|
36
|
+
if res.count > 0 # TODO raise if this fails?
|
37
|
+
body_node = RDF::Node.new
|
38
|
+
@oa_graph << [@root_uri, RDF::OpenAnnotation.hasBody, body_node]
|
39
|
+
@oa_graph << [body_node, RDF.type, RDF::Content.ContentAsText]
|
40
|
+
@oa_graph << [body_node, RDF.type, RDF::DCMIType.Text]
|
41
|
+
res_chars = @ldp.stripped_graph.query [body_uri, RDF::Content.chars, nil]
|
42
|
+
if res_chars.count > 0
|
43
|
+
@oa_graph << [body_node, RDF::Content.chars, res_chars.first.object]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def extract_target
|
50
|
+
@ldp.target_uris.each { |target_uri|
|
51
|
+
res = @ldp.stripped_graph.query [target_uri, RDF::Triannon.externalReference, nil]
|
52
|
+
if res.count > 0
|
53
|
+
ext_uri = res.first.object
|
54
|
+
@oa_graph << [@root_uri, RDF::OpenAnnotation.hasTarget, ext_uri]
|
55
|
+
end
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Triannon
|
2
|
+
|
3
|
+
class RootAnnotationCreator
|
4
|
+
|
5
|
+
# Creates an LDP Container to hold all the annotations
|
6
|
+
# Called from config/initializers/root_annotation_container.rb during app bootup
|
7
|
+
# @return [Boolean] true if the root container was created, false if the container already exists or if there were issues
|
8
|
+
def self.create
|
9
|
+
conn = Faraday.new :url => Triannon.config[:ldp_url]
|
10
|
+
resp = conn.head
|
11
|
+
unless resp.status == 404 || resp.status == 410
|
12
|
+
Rails.logger.info "Root annotation resource already created."
|
13
|
+
return false
|
14
|
+
end
|
15
|
+
|
16
|
+
uri = RDF::URI.new Triannon.config[:ldp_url]
|
17
|
+
conn = Faraday.new :url => uri.parent.to_s
|
18
|
+
slug = uri.to_s.split('/').last
|
19
|
+
|
20
|
+
resp = conn.post do |req|
|
21
|
+
req.headers['Content-Type'] = 'text/turtle'
|
22
|
+
req.headers['Slug'] = slug
|
23
|
+
end
|
24
|
+
|
25
|
+
if resp.status == 201
|
26
|
+
Rails.logger.info "Created root annotation container #{Triannon.config[:ldp_url]}"
|
27
|
+
return true
|
28
|
+
else
|
29
|
+
Rails.logger.warn "Unable to create root annotation container #{Triannon.config[:ldp_url]}"
|
30
|
+
return false
|
31
|
+
# TODO raise an exception if we get here?
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|