osullivan 0.0.2
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 +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
|
+
|