osullivan 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.rspec +3 -0
- data/.travis.yml +4 -0
- data/Gemfile +2 -0
- data/LICENSE +23 -0
- data/README.md +166 -0
- data/Rakefile +12 -0
- data/VERSION +1 -0
- data/lib/active_support/ordered_hash.rb +147 -0
- data/lib/iiif/hash_behaviours.rb +150 -0
- data/lib/iiif/presentation.rb +25 -0
- data/lib/iiif/presentation/abstract_resource.rb +75 -0
- data/lib/iiif/presentation/annotation.rb +25 -0
- data/lib/iiif/presentation/annotation_list.rb +28 -0
- data/lib/iiif/presentation/canvas.rb +45 -0
- data/lib/iiif/presentation/collection.rb +29 -0
- data/lib/iiif/presentation/image_resource.rb +115 -0
- data/lib/iiif/presentation/layer.rb +34 -0
- data/lib/iiif/presentation/manifest.rb +39 -0
- data/lib/iiif/presentation/range.rb +32 -0
- data/lib/iiif/presentation/resource.rb +21 -0
- data/lib/iiif/presentation/sequence.rb +35 -0
- data/lib/iiif/service.rb +418 -0
- data/osullivan.gemspec +27 -0
- data/spec/fixtures/manifests/complete_from_spec.json +171 -0
- data/spec/fixtures/manifests/minimal.json +40 -0
- data/spec/fixtures/manifests/service_only.json +11 -0
- data/spec/fixtures/vcr_cassettes/pul_loris_cassette.json +159 -0
- data/spec/integration/iiif/presentation/image_resource_spec.rb +123 -0
- data/spec/integration/iiif/service_spec.rb +211 -0
- data/spec/spec_helper.rb +104 -0
- data/spec/unit/active_support/ordered_hash_spec.rb +155 -0
- data/spec/unit/iiif/hash_behaviours_spec.rb +569 -0
- data/spec/unit/iiif/presentation/abstract_resource_spec.rb +133 -0
- data/spec/unit/iiif/presentation/annotation_list_spec.rb +7 -0
- data/spec/unit/iiif/presentation/annotation_spec.rb +7 -0
- data/spec/unit/iiif/presentation/canvas_spec.rb +40 -0
- data/spec/unit/iiif/presentation/collection_spec.rb +54 -0
- data/spec/unit/iiif/presentation/image_resource_spec.rb +13 -0
- data/spec/unit/iiif/presentation/layer_spec.rb +38 -0
- data/spec/unit/iiif/presentation/manifest_spec.rb +89 -0
- data/spec/unit/iiif/presentation/range_spec.rb +43 -0
- data/spec/unit/iiif/presentation/resource_spec.rb +16 -0
- data/spec/unit/iiif/presentation/sequence_spec.rb +110 -0
- data/spec/unit/iiif/presentation/shared_examples/abstract_resource_only_keys.rb +43 -0
- data/spec/unit/iiif/presentation/shared_examples/any_type_keys.rb +33 -0
- data/spec/unit/iiif/presentation/shared_examples/array_only_keys.rb +44 -0
- data/spec/unit/iiif/presentation/shared_examples/int_only_keys.rb +49 -0
- data/spec/unit/iiif/presentation/shared_examples/string_only_keys.rb +29 -0
- data/spec/unit/iiif/service_spec.rb +10 -0
- metadata +246 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'abstract_resource')
|
2
|
+
|
3
|
+
module IIIF
|
4
|
+
module Presentation
|
5
|
+
class Resource < AbstractResource
|
6
|
+
|
7
|
+
def required_keys
|
8
|
+
%w{ @id }
|
9
|
+
end
|
10
|
+
|
11
|
+
def string_only_keys
|
12
|
+
super + %w{ format }
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(hsh={})
|
16
|
+
super(hsh)
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'abstract_resource')
|
2
|
+
|
3
|
+
module IIIF
|
4
|
+
module Presentation
|
5
|
+
class Sequence < AbstractResource
|
6
|
+
|
7
|
+
TYPE = 'sc:Sequence'
|
8
|
+
|
9
|
+
def array_only_keys
|
10
|
+
super + %w{ canvases }
|
11
|
+
end
|
12
|
+
|
13
|
+
def string_only_keys
|
14
|
+
super + %w{ start_canvas viewing_direction }
|
15
|
+
end
|
16
|
+
|
17
|
+
def legal_viewing_hint_values
|
18
|
+
%w{ individuals paged continuous }
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(hsh={})
|
22
|
+
hsh['@type'] = TYPE unless hsh.has_key? '@type'
|
23
|
+
super(hsh)
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate
|
27
|
+
# * Must be at least one canvas
|
28
|
+
# * All members of canvases must be a kind of Canvas
|
29
|
+
super
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
data/lib/iiif/service.rb
ADDED
@@ -0,0 +1,418 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'hash_behaviours')
|
2
|
+
require 'active_support/ordered_hash'
|
3
|
+
require 'active_support/inflector'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module IIIF
|
7
|
+
class Service
|
8
|
+
include IIIF::HashBehaviours
|
9
|
+
|
10
|
+
# Anything goes! SHOULD have @id and profile, MAY have label
|
11
|
+
# Consider subclassing this for typical services...
|
12
|
+
def required_keys; %w{ }; end
|
13
|
+
def any_type_keys; %w{ }; end
|
14
|
+
def string_only_keys; %w{ }; end
|
15
|
+
def array_only_keys; %w{ }; end
|
16
|
+
def abstract_resource_only_keys; %w{ }; end
|
17
|
+
def hash_only_keys; %w{ }; end
|
18
|
+
def int_only_keys; %w{ }; end
|
19
|
+
|
20
|
+
def initialize(hsh={})
|
21
|
+
@data = ActiveSupport::OrderedHash[hsh]
|
22
|
+
self.define_methods_for_any_type_keys
|
23
|
+
self.define_methods_for_array_only_keys
|
24
|
+
self.define_methods_for_string_only_keys
|
25
|
+
self.define_methods_for_int_only_keys
|
26
|
+
self.define_methods_for_abstract_resource_only_keys
|
27
|
+
self.snakeize_keys
|
28
|
+
end
|
29
|
+
|
30
|
+
# Static methods / alternative constructors
|
31
|
+
class << self
|
32
|
+
# Parse from a file path, string, or existing hash
|
33
|
+
def parse(s)
|
34
|
+
ordered_hash = nil
|
35
|
+
if s.kind_of?(String) && File.exists?(s)
|
36
|
+
ordered_hash = ActiveSupport::OrderedHash[JSON.parse(IO.read(s))]
|
37
|
+
elsif s.kind_of?(String) && !File.exists?(s)
|
38
|
+
ordered_hash = ActiveSupport::OrderedHash[JSON.parse(s)]
|
39
|
+
elsif s.kind_of?(Hash)
|
40
|
+
ordered_hash = ActiveSupport::OrderedHash[s]
|
41
|
+
else
|
42
|
+
m = '#parse takes a path to a file, a JSON String, or a Hash, '
|
43
|
+
m += "argument was a #{s.class}."
|
44
|
+
if s.kind_of?(String)
|
45
|
+
m+= "If you were trying to point to a file, does it exist?"
|
46
|
+
end
|
47
|
+
raise ArgumentError, m
|
48
|
+
end
|
49
|
+
return IIIF::Service.from_ordered_hash(ordered_hash)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate
|
54
|
+
# TODO:
|
55
|
+
# * check for required keys
|
56
|
+
# * type check Array-only values
|
57
|
+
# * type check String-only values
|
58
|
+
# * type check Integer-only values
|
59
|
+
# * type check AbstractResource-only values
|
60
|
+
self.required_keys.each do |k|
|
61
|
+
unless self.has_key?(k)
|
62
|
+
m = "A(n) #{k} is required for each #{self.class}"
|
63
|
+
raise IIIF::Presentation::MissingRequiredKeyError, m
|
64
|
+
end
|
65
|
+
end
|
66
|
+
# Viewing Direction values
|
67
|
+
if self.has_key?('viewing_direction')
|
68
|
+
unless self.legal_viewing_direction_values.include?(self['viewing_direction'])
|
69
|
+
m = "viewingDirection must be one of #{legal_viewing_direction_values}"
|
70
|
+
raise IIIF::Presentation::IllegalValueError, m
|
71
|
+
end
|
72
|
+
end
|
73
|
+
# Viewing Hint values
|
74
|
+
if self.has_key?('viewing_hint')
|
75
|
+
unless self.legal_viewing_hint_values.include?(self['viewing_hint'])
|
76
|
+
m = "viewingHint for #{self.class} must be one of #{self.legal_viewing_hint_values}."
|
77
|
+
raise IIIF::Presentation::IllegalValueError, m
|
78
|
+
end
|
79
|
+
end
|
80
|
+
# Metadata is all hashes
|
81
|
+
if self.has_key?('metadata')
|
82
|
+
unless self['metadata'].all? { |entry| entry.kind_of?(Hash) }
|
83
|
+
m = 'All entries in the metadata list must be a type of Hash'
|
84
|
+
raise IIIF::Presentation::IllegalValueError, m
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Options
|
90
|
+
# * pretty: (true|false). Should the JSON be pretty-printed? (default: false)
|
91
|
+
# * All options available in #to_ordered_hash
|
92
|
+
def to_json(opts={})
|
93
|
+
hsh = self.to_ordered_hash(opts)
|
94
|
+
if opts.fetch(:pretty, false)
|
95
|
+
JSON.pretty_generate(hsh)
|
96
|
+
else
|
97
|
+
hsh.to_json
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Options:
|
102
|
+
# * force: (true|false). Skips validations.
|
103
|
+
# * sort_json_ld_keys: (true|false). Brings all properties starting with
|
104
|
+
# '@'. Default: true. to the top of the document and sorts them.
|
105
|
+
def to_ordered_hash(opts={})
|
106
|
+
force = opts.fetch(:force, false)
|
107
|
+
sort_json_ld_keys = opts.fetch(:sort_json_ld_keys, true)
|
108
|
+
|
109
|
+
unless force
|
110
|
+
self.validate
|
111
|
+
end
|
112
|
+
|
113
|
+
export_hash = ActiveSupport::OrderedHash.new
|
114
|
+
|
115
|
+
if sort_json_ld_keys
|
116
|
+
self.keys.select { |k| k.start_with?('@') }.sort!.each do |k|
|
117
|
+
export_hash[k] = self.data[k]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
sub_opts = {
|
122
|
+
include_context: false,
|
123
|
+
sort_json_ld_keys: sort_json_ld_keys,
|
124
|
+
force: force
|
125
|
+
}
|
126
|
+
self.keys.each do |k|
|
127
|
+
unless sort_json_ld_keys && k.start_with?('@')
|
128
|
+
if self.data[k].respond_to?(:to_ordered_hash) #.respond_to?(:to_ordered_hash)
|
129
|
+
export_hash[k] = self.data[k].to_ordered_hash(sub_opts)
|
130
|
+
|
131
|
+
elsif self.data[k].kind_of?(Hash)
|
132
|
+
export_hash[k] = ActiveSupport::OrderedHash.new
|
133
|
+
self.data[k].each do |sub_k, v|
|
134
|
+
|
135
|
+
if v.respond_to?(:to_ordered_hash)
|
136
|
+
export_hash[k][sub_k] = v.to_ordered_hash(sub_opts)
|
137
|
+
|
138
|
+
elsif v.kind_of?(Array)
|
139
|
+
export_hash[k][sub_k] = []
|
140
|
+
v.each do |member|
|
141
|
+
if member.respond_to?(:to_ordered_hash)
|
142
|
+
export_hash[k][sub_k] << member.to_ordered_hash(sub_opts)
|
143
|
+
else
|
144
|
+
export_hash[k][sub_k] << member
|
145
|
+
end
|
146
|
+
end
|
147
|
+
else
|
148
|
+
export_hash[k][sub_k] = v
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
elsif self.data[k].kind_of?(Array)
|
153
|
+
export_hash[k] = []
|
154
|
+
|
155
|
+
self.data[k].each do |member|
|
156
|
+
if member.respond_to?(:to_ordered_hash)
|
157
|
+
export_hash[k] << member.to_ordered_hash(sub_opts)
|
158
|
+
|
159
|
+
elsif member.kind_of?(Hash)
|
160
|
+
hsh = ActiveSupport::OrderedHash.new
|
161
|
+
export_hash[k] << hsh
|
162
|
+
member.each do |sub_k,v|
|
163
|
+
|
164
|
+
if v.respond_to?(:to_ordered_hash)
|
165
|
+
hsh[sub_k] = v.to_ordered_hash(sub_opts)
|
166
|
+
|
167
|
+
elsif v.kind_of?(Array)
|
168
|
+
hsh[sub_k] = []
|
169
|
+
|
170
|
+
v.each do |sub_member|
|
171
|
+
if sub_member.respond_to?(:to_ordered_hash)
|
172
|
+
hsh[sub_k] << sub_member.to_ordered_hash(sub_opts)
|
173
|
+
else
|
174
|
+
hsh[sub_k] << sub_member
|
175
|
+
end
|
176
|
+
end
|
177
|
+
else
|
178
|
+
hsh[sub_k] = v
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
else
|
183
|
+
export_hash[k] << member
|
184
|
+
# there are no nested arrays, right?
|
185
|
+
end
|
186
|
+
end
|
187
|
+
else
|
188
|
+
export_hash[k] = self.data[k]
|
189
|
+
end
|
190
|
+
|
191
|
+
end
|
192
|
+
end
|
193
|
+
export_hash.remove_empties
|
194
|
+
export_hash.camelize_keys
|
195
|
+
export_hash
|
196
|
+
end
|
197
|
+
|
198
|
+
def self.from_ordered_hash(hsh, default_klass=ActiveSupport::OrderedHash)
|
199
|
+
# Create a new object (new_object)
|
200
|
+
type = nil
|
201
|
+
if hsh.has_key?('@type')
|
202
|
+
type = IIIF::Service.get_descendant_class_by_jld_type(hsh['@type'])
|
203
|
+
end
|
204
|
+
new_object = type.nil? ? default_klass.new : type.new
|
205
|
+
|
206
|
+
hsh.keys.each do |key|
|
207
|
+
new_key = key.underscore == key ? key : key.underscore
|
208
|
+
if new_key == 'service'
|
209
|
+
new_object[new_key] = IIIF::Service.from_ordered_hash(hsh[key], IIIF::Service)
|
210
|
+
elsif new_key == 'resource'
|
211
|
+
new_object[new_key] = IIIF::Service.from_ordered_hash(hsh[key], IIIF::Presentation::Resource)
|
212
|
+
elsif hsh[key].kind_of?(Hash)
|
213
|
+
new_object[new_key] = IIIF::Service.from_ordered_hash(hsh[key])
|
214
|
+
elsif hsh[key].kind_of?(Array)
|
215
|
+
new_object[new_key] = []
|
216
|
+
hsh[key].each do |member|
|
217
|
+
if new_key == 'service'
|
218
|
+
new_object[new_key] << IIIF::Service.from_ordered_hash(member, IIIF::Service)
|
219
|
+
elsif member.kind_of?(Hash)
|
220
|
+
new_object[new_key] << IIIF::Service.from_ordered_hash(member)
|
221
|
+
else
|
222
|
+
new_object[new_key] << member
|
223
|
+
# Again, no nested arrays, right?
|
224
|
+
end
|
225
|
+
end
|
226
|
+
else
|
227
|
+
new_object[new_key] = hsh[key]
|
228
|
+
end
|
229
|
+
end
|
230
|
+
new_object
|
231
|
+
end
|
232
|
+
|
233
|
+
protected
|
234
|
+
|
235
|
+
def self.get_descendant_class_by_jld_type(type)
|
236
|
+
IIIF::Service.all_service_subclasses.select { |klass|
|
237
|
+
klass.const_defined?(:TYPE) && klass.const_get(:TYPE) == type
|
238
|
+
}.first
|
239
|
+
end
|
240
|
+
|
241
|
+
# All known subclasses of service.
|
242
|
+
def self.all_service_subclasses
|
243
|
+
klass = IIIF::Service
|
244
|
+
# !c.name.nil? filters out classes that rspec creates for some reason;
|
245
|
+
# this condition isn't necessary when using the API, afaik
|
246
|
+
descendants = ObjectSpace.each_object(Class).select { |c| c < klass && !c.name.nil? }
|
247
|
+
end
|
248
|
+
|
249
|
+
def data=(hsh)
|
250
|
+
@data = hsh
|
251
|
+
end
|
252
|
+
|
253
|
+
def data
|
254
|
+
@data
|
255
|
+
end
|
256
|
+
|
257
|
+
def define_methods_for_any_type_keys
|
258
|
+
any_type_keys.each do |key|
|
259
|
+
# Setters
|
260
|
+
define_singleton_method("#{key}=") do |arg|
|
261
|
+
self.send('[]=', key, arg)
|
262
|
+
end
|
263
|
+
if key.camelize(:lower) != key
|
264
|
+
define_singleton_method("#{key.camelize(:lower)}=") do |arg|
|
265
|
+
self.send('[]=', key, arg)
|
266
|
+
end
|
267
|
+
end
|
268
|
+
# Getters
|
269
|
+
define_singleton_method(key) do
|
270
|
+
self.send('[]', key)
|
271
|
+
end
|
272
|
+
if key.camelize(:lower) != key
|
273
|
+
define_singleton_method(key.camelize(:lower)) do
|
274
|
+
self.send('[]', key)
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def define_methods_for_array_only_keys
|
281
|
+
array_only_keys.each do |key|
|
282
|
+
# Setters
|
283
|
+
define_singleton_method("#{key}=") do |arg|
|
284
|
+
unless arg.kind_of?(Array)
|
285
|
+
m = "#{key} must be an Array."
|
286
|
+
raise IIIF::Presentation::IllegalValueError, m
|
287
|
+
end
|
288
|
+
self.send('[]=', key, arg)
|
289
|
+
end
|
290
|
+
if key.camelize(:lower) != key
|
291
|
+
define_singleton_method("#{key.camelize(:lower)}=") do |arg|
|
292
|
+
unless arg.kind_of?(Array)
|
293
|
+
m = "#{key} must be an Array."
|
294
|
+
raise IIIF::Presentation::IllegalValueError, m
|
295
|
+
end
|
296
|
+
self.send('[]=', key, arg)
|
297
|
+
end
|
298
|
+
end
|
299
|
+
# Getters
|
300
|
+
define_singleton_method(key) do
|
301
|
+
self[key] ||= []
|
302
|
+
self[key]
|
303
|
+
end
|
304
|
+
if key.camelize(:lower) != key
|
305
|
+
define_singleton_method(key.camelize(:lower)) do
|
306
|
+
self.send('[]', key)
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def define_methods_for_abstract_resource_only_keys
|
313
|
+
# keys in this case is an array of hashes with { key: 'k', type: Class }
|
314
|
+
abstract_resource_only_keys.each do |hsh|
|
315
|
+
key = hsh[:key]
|
316
|
+
type = hsh[:type]
|
317
|
+
# Setters
|
318
|
+
define_singleton_method("#{key}=") do |arg|
|
319
|
+
unless arg.kind_of?(type)
|
320
|
+
m = "#{key} must be an #{type}."
|
321
|
+
raise IIIF::Presentation::IllegalValueError, m
|
322
|
+
end
|
323
|
+
self.send('[]=', key, arg)
|
324
|
+
end
|
325
|
+
if key.camelize(:lower) != key
|
326
|
+
define_singleton_method("#{key.camelize(:lower)}=") do |arg|
|
327
|
+
unless arg.kind_of?(type)
|
328
|
+
m = "#{key} must be an #{type}."
|
329
|
+
raise IIIF::Presentation::IllegalValueError, m
|
330
|
+
end
|
331
|
+
self.send('[]=', key, arg)
|
332
|
+
end
|
333
|
+
end
|
334
|
+
# Getters
|
335
|
+
define_singleton_method(key) do
|
336
|
+
self[key] ||= []
|
337
|
+
self[key]
|
338
|
+
end
|
339
|
+
if key.camelize(:lower) != key
|
340
|
+
define_singleton_method(key.camelize(:lower)) do
|
341
|
+
self.send('[]', key)
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
|
348
|
+
def define_methods_for_string_only_keys
|
349
|
+
string_only_keys.each do |key|
|
350
|
+
# Setter
|
351
|
+
define_singleton_method("#{key}=") do |arg|
|
352
|
+
unless arg.kind_of?(String)
|
353
|
+
m = "#{key} must be an String."
|
354
|
+
raise IIIF::Presentation::IllegalValueError, m
|
355
|
+
end
|
356
|
+
self.send('[]=', key, arg)
|
357
|
+
end
|
358
|
+
if key.camelize(:lower) != key
|
359
|
+
define_singleton_method("#{key.camelize(:lower)}=") do |arg|
|
360
|
+
unless arg.kind_of?(String)
|
361
|
+
m = "#{key} must be an String."
|
362
|
+
raise IIIF::Presentation::IllegalValueError, m
|
363
|
+
end
|
364
|
+
self.send('[]=', key, arg)
|
365
|
+
end
|
366
|
+
end
|
367
|
+
# Getter
|
368
|
+
define_singleton_method(key) do
|
369
|
+
self[key] ||= []
|
370
|
+
self[key]
|
371
|
+
end
|
372
|
+
if key.camelize(:lower) != key
|
373
|
+
define_singleton_method(key.camelize(:lower)) do
|
374
|
+
self.send('[]', key)
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
def define_methods_for_int_only_keys
|
381
|
+
int_only_keys.each do |key|
|
382
|
+
# Setter
|
383
|
+
define_singleton_method("#{key}=") do |arg|
|
384
|
+
unless arg.kind_of?(Integer) && arg > 0
|
385
|
+
m = "#{key} must be a positive Integer."
|
386
|
+
raise IIIF::Presentation::IllegalValueError, m
|
387
|
+
end
|
388
|
+
self.send('[]=', key, arg)
|
389
|
+
end
|
390
|
+
if key.camelize(:lower) != key
|
391
|
+
define_singleton_method("#{key.camelize(:lower)}=") do |arg|
|
392
|
+
unless arg.kind_of?(Integer) && arg > 0
|
393
|
+
m = "#{key} must be a positive Integer."
|
394
|
+
raise IIIF::Presentation::IllegalValueError, m
|
395
|
+
end
|
396
|
+
self.send('[]=', key, arg)
|
397
|
+
end
|
398
|
+
end
|
399
|
+
# Getter
|
400
|
+
define_singleton_method(key) do
|
401
|
+
self[key] ||= []
|
402
|
+
self[key]
|
403
|
+
end
|
404
|
+
if key.camelize(:lower) != key
|
405
|
+
define_singleton_method(key.camelize(:lower)) do
|
406
|
+
self.send('[]', key)
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
end
|
411
|
+
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
|
416
|
+
|
417
|
+
|
418
|
+
|