wcc-contentful 0.0.3 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +51 -0
- data/.gitignore +8 -0
- data/.rspec +1 -0
- data/.rubocop.yml +240 -0
- data/.rubocop_todo.yml +13 -0
- data/CHANGELOG.md +7 -1
- data/Gemfile +4 -2
- data/Guardfile +36 -0
- data/README.md +1 -1
- data/Rakefile +5 -3
- data/bin/rspec +3 -0
- data/lib/generators/wcc/USAGE +24 -0
- data/lib/generators/wcc/menu_generator.rb +67 -0
- data/lib/generators/wcc/templates/.keep +0 -0
- data/lib/generators/wcc/templates/Procfile +3 -0
- data/lib/generators/wcc/templates/contentful_shell_wrapper +342 -0
- data/lib/generators/wcc/templates/menu/generated_add_menus.ts +85 -0
- data/lib/generators/wcc/templates/menu/menu.rb +25 -0
- data/lib/generators/wcc/templates/menu/menu_button.rb +25 -0
- data/lib/generators/wcc/templates/release +9 -0
- data/lib/generators/wcc/templates/wcc_contentful.rb +18 -0
- data/lib/wcc/contentful.rb +93 -26
- data/lib/wcc/contentful/client_ext.rb +15 -0
- data/lib/wcc/contentful/configuration.rb +93 -0
- data/lib/wcc/contentful/content_type_indexer.rb +153 -0
- data/lib/wcc/contentful/exceptions.rb +34 -0
- data/lib/wcc/contentful/graphql.rb +15 -0
- data/lib/wcc/contentful/graphql/builder.rb +172 -0
- data/lib/wcc/contentful/graphql/types.rb +54 -0
- data/lib/wcc/contentful/helpers.rb +28 -0
- data/lib/wcc/contentful/indexed_representation.rb +111 -0
- data/lib/wcc/contentful/model.rb +24 -0
- data/lib/wcc/contentful/model/menu.rb +7 -0
- data/lib/wcc/contentful/model/menu_button.rb +15 -0
- data/lib/wcc/contentful/model_builder.rb +151 -0
- data/lib/wcc/contentful/model_validators.rb +64 -0
- data/lib/wcc/contentful/model_validators/dsl.rb +165 -0
- data/lib/wcc/contentful/simple_client.rb +127 -0
- data/lib/wcc/contentful/simple_client/response.rb +160 -0
- data/lib/wcc/contentful/store.rb +8 -0
- data/lib/wcc/contentful/store/cdn_adapter.rb +79 -0
- data/lib/wcc/contentful/store/memory_store.rb +75 -0
- data/lib/wcc/contentful/store/postgres_store.rb +132 -0
- data/lib/wcc/contentful/version.rb +3 -1
- data/wcc-contentful.gemspec +49 -24
- metadata +261 -16
- data/lib/wcc/contentful/redirect.rb +0 -33
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class WCC::Contentful::Model::MenuButton < WCC::Contentful::Model
|
4
|
+
validate_field :text, :String, :required
|
5
|
+
validate_field :icon, :Asset, :optional
|
6
|
+
validate_field :external_link, :String, :optional
|
7
|
+
validate_field :link, :Link, :optional, link_to: 'page'
|
8
|
+
|
9
|
+
# Gets either the external link or the slug from the referenced page.
|
10
|
+
# Example usage: `<%= link_to button.title, button.href %>`
|
11
|
+
def href
|
12
|
+
return external_link if external_link
|
13
|
+
link&.try(:slug) || link&.try(:url)
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WCC::Contentful
|
4
|
+
class ModelBuilder
|
5
|
+
include Helpers
|
6
|
+
|
7
|
+
def initialize(types)
|
8
|
+
@types = types
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_models
|
12
|
+
@types.each_with_object([]) do |(_k, v), a|
|
13
|
+
a << build_model(v)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def build_model(typedef)
|
20
|
+
const = typedef.name
|
21
|
+
return WCC::Contentful::Model.const_get(const) if WCC::Contentful::Model.const_defined?(const)
|
22
|
+
|
23
|
+
# TODO: https://github.com/dkubb/ice_nine ?
|
24
|
+
typedef = typedef.deep_dup.freeze
|
25
|
+
fields = typedef.fields.keys
|
26
|
+
WCC::Contentful::Model.const_set(const,
|
27
|
+
Class.new(WCC::Contentful::Model) do
|
28
|
+
include Helpers
|
29
|
+
|
30
|
+
define_singleton_method(:content_type) do
|
31
|
+
typedef.content_type
|
32
|
+
end
|
33
|
+
|
34
|
+
define_singleton_method(:content_type_definition) do
|
35
|
+
typedef
|
36
|
+
end
|
37
|
+
|
38
|
+
define_singleton_method(:find) do |id, context = nil|
|
39
|
+
raw = WCC::Contentful::Model.store.find(id)
|
40
|
+
new(raw, context) if raw.present?
|
41
|
+
end
|
42
|
+
|
43
|
+
define_singleton_method(:find_all) do |context = nil|
|
44
|
+
raw = WCC::Contentful::Model.store.find_by(content_type: content_type)
|
45
|
+
raw.map { |r| new(r, context) }
|
46
|
+
end
|
47
|
+
|
48
|
+
define_singleton_method(:find_by) do |filter, context = nil|
|
49
|
+
filter.transform_keys! { |k| k.to_s.camelize(:lower) }
|
50
|
+
bad_fields = filter.keys.reject { |k| fields.include?(k) }
|
51
|
+
raise ArgumentError, "These fields do not exist: #{bad_fields}" unless bad_fields.empty?
|
52
|
+
|
53
|
+
query = WCC::Contentful::Model.store.find_by(content_type: content_type)
|
54
|
+
filter.each do |field, v|
|
55
|
+
query = query.eq(field, v, context)
|
56
|
+
end
|
57
|
+
query.map { |r| new(r, context) }
|
58
|
+
end
|
59
|
+
|
60
|
+
define_method(:initialize) do |raw, context = nil|
|
61
|
+
ct = content_type_from_raw(raw)
|
62
|
+
if ct != typedef.content_type
|
63
|
+
raise ArgumentError, 'Wrong Content Type - ' \
|
64
|
+
"'#{raw.dig('sys', 'id')}' is a #{ct}, expected #{typedef.content_type}"
|
65
|
+
end
|
66
|
+
|
67
|
+
@locale = context[:locale] if context.present?
|
68
|
+
@locale ||= 'en-US'
|
69
|
+
@id = raw.dig('sys', 'id')
|
70
|
+
@space = raw.dig('sys', 'space', 'sys', 'id')
|
71
|
+
@created_at = raw.dig('sys', 'createdAt')
|
72
|
+
@created_at = Time.parse(@created_at) if @created_at.present?
|
73
|
+
@updated_at = raw.dig('sys', 'updatedAt')
|
74
|
+
@updated_at = Time.parse(@updated_at) if @updated_at.present?
|
75
|
+
@revision = raw.dig('sys', 'revision')
|
76
|
+
|
77
|
+
typedef.fields.each_value do |f|
|
78
|
+
raw_value = raw.dig('fields', f.name, @locale)
|
79
|
+
if raw_value.present?
|
80
|
+
case f.type
|
81
|
+
when :DateTime
|
82
|
+
raw_value = Time.zone.parse(raw_value)
|
83
|
+
when :Int
|
84
|
+
raw_value = Integer(raw_value)
|
85
|
+
when :Float
|
86
|
+
raw_value = Float(raw_value)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
instance_variable_set('@' + f.name, raw_value)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
attr_reader :id
|
94
|
+
attr_reader :space
|
95
|
+
attr_reader :created_at
|
96
|
+
attr_reader :updated_at
|
97
|
+
attr_reader :revision
|
98
|
+
|
99
|
+
# Make a field for each column:
|
100
|
+
typedef.fields.each_value do |f|
|
101
|
+
name = f.name
|
102
|
+
var_name = '@' + name
|
103
|
+
case f.type
|
104
|
+
when :Asset, :Link
|
105
|
+
define_method(name) do
|
106
|
+
val = instance_variable_get(var_name + '_resolved')
|
107
|
+
return val if val.present?
|
108
|
+
|
109
|
+
return unless val = instance_variable_get(var_name)
|
110
|
+
|
111
|
+
val =
|
112
|
+
if val.is_a? Array
|
113
|
+
val.map { |v| WCC::Contentful::Model.find(v.dig('sys', 'id')) }
|
114
|
+
else
|
115
|
+
WCC::Contentful::Model.find(val.dig('sys', 'id'))
|
116
|
+
end
|
117
|
+
|
118
|
+
instance_variable_set(var_name + '_resolved', val)
|
119
|
+
val
|
120
|
+
end
|
121
|
+
when :Coordinates
|
122
|
+
define_method(name) do
|
123
|
+
val = instance_variable_get(var_name)
|
124
|
+
OpenStruct.new(val.slice('lat', 'lon')) if val
|
125
|
+
end
|
126
|
+
when :Json
|
127
|
+
define_method(name) do
|
128
|
+
value = instance_variable_get(var_name)
|
129
|
+
|
130
|
+
parse_value =
|
131
|
+
->(v) do
|
132
|
+
return v.to_h if v.respond_to?(:to_h)
|
133
|
+
|
134
|
+
raise ArgumentError, "Cannot coerce value '#{value}' to a hash"
|
135
|
+
end
|
136
|
+
|
137
|
+
return value.map { |v| OpenStruct.new(parse_value.call(v)) } if value.is_a?(Array)
|
138
|
+
|
139
|
+
OpenStruct.new(parse_value.call(value))
|
140
|
+
end
|
141
|
+
else
|
142
|
+
define_method(name) do
|
143
|
+
instance_variable_get(var_name)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
alias_method name.underscore, name
|
147
|
+
end
|
148
|
+
end)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry-validation'
|
4
|
+
|
5
|
+
require_relative 'model_validators/dsl'
|
6
|
+
|
7
|
+
module WCC::Contentful::ModelValidators
|
8
|
+
def schema
|
9
|
+
return if @field_validations.nil? || @field_validations.empty?
|
10
|
+
field_validations = @field_validations
|
11
|
+
|
12
|
+
# "page": {
|
13
|
+
# "sys": { ... }
|
14
|
+
# "fields": {
|
15
|
+
# "title": { ... },
|
16
|
+
# "sections": { ... },
|
17
|
+
# ...
|
18
|
+
# }
|
19
|
+
# }
|
20
|
+
|
21
|
+
fields_schema =
|
22
|
+
Dry::Validation.Schema do
|
23
|
+
# Had to dig through the internals of Dry::Validation to find
|
24
|
+
# this magic incantation
|
25
|
+
field_validations.each { |dsl| instance_eval(&dsl.to_proc) }
|
26
|
+
end
|
27
|
+
|
28
|
+
Dry::Validation.Schema do
|
29
|
+
required('fields').schema(fields_schema)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_fields(&block)
|
34
|
+
raise ArgumentError, 'validate_fields requires a block' unless block_given?
|
35
|
+
dsl = ProcDsl.new(Proc.new(&block))
|
36
|
+
|
37
|
+
(@field_validations ||= []) << dsl
|
38
|
+
end
|
39
|
+
|
40
|
+
def validate_field(field, type, *options)
|
41
|
+
dsl = FieldDsl.new(field, type, options)
|
42
|
+
|
43
|
+
(@field_validations ||= []) << dsl
|
44
|
+
end
|
45
|
+
|
46
|
+
# Accepts a content types response from the API and transforms it
|
47
|
+
# to be acceptible for the validator.
|
48
|
+
def self.transform_content_types_for_validation(content_types)
|
49
|
+
if !content_types.is_a?(Array) && items = content_types.try(:[], 'items')
|
50
|
+
content_types = items
|
51
|
+
end
|
52
|
+
|
53
|
+
# Transform the array into a hash keyed by content type ID
|
54
|
+
content_types.each_with_object({}) do |ct, ct_hash|
|
55
|
+
# Transform the fields into a hash keyed by field ID
|
56
|
+
ct['fields'] =
|
57
|
+
ct['fields'].each_with_object({}) do |f, f_hash|
|
58
|
+
f_hash[f['id']] = f
|
59
|
+
end
|
60
|
+
|
61
|
+
ct_hash[ct.dig('sys', 'id')] = ct
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WCC::Contentful::ModelValidators
|
4
|
+
class ProcDsl
|
5
|
+
def to_proc
|
6
|
+
@proc
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(proc)
|
10
|
+
@proc = proc
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class FieldDsl
|
15
|
+
attr_reader :field
|
16
|
+
|
17
|
+
# "sections": {
|
18
|
+
# "id": "sections",
|
19
|
+
# "name": "Sections",
|
20
|
+
# "type": "Array",
|
21
|
+
# "localized": false,
|
22
|
+
# "required": false,
|
23
|
+
# "validations": [],
|
24
|
+
# "disabled": false,
|
25
|
+
# "omitted": false,
|
26
|
+
# "items": {
|
27
|
+
# "type": "Link",
|
28
|
+
# "validations": [
|
29
|
+
# {
|
30
|
+
# "linkContentType": [
|
31
|
+
# "Section"
|
32
|
+
# ]
|
33
|
+
# }
|
34
|
+
# ],
|
35
|
+
# "linkType": "Entry"
|
36
|
+
# }
|
37
|
+
# }
|
38
|
+
|
39
|
+
def schema
|
40
|
+
return @field_schema if @field_schema
|
41
|
+
|
42
|
+
# example: required('type').value(...)
|
43
|
+
type_pred = parse_type_predicate(@type)
|
44
|
+
|
45
|
+
# example: [required('required').value(eq?: true), ...]
|
46
|
+
procs =
|
47
|
+
@options.map do |opt|
|
48
|
+
if opt.is_a?(Hash)
|
49
|
+
opt.map { |k, v| parse_option(k, v) }
|
50
|
+
else
|
51
|
+
parse_option(opt)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
@field_schema =
|
56
|
+
Dry::Validation.Schema do
|
57
|
+
instance_eval(&type_pred)
|
58
|
+
|
59
|
+
procs.flatten.each { |p| instance_eval(&p) }
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def to_proc
|
64
|
+
f = field
|
65
|
+
s = schema
|
66
|
+
proc { required(f).schema(s) }
|
67
|
+
end
|
68
|
+
|
69
|
+
def initialize(field, field_type, options)
|
70
|
+
@field = field.to_s.camelize(:lower) unless field.is_a?(String)
|
71
|
+
@type = field_type
|
72
|
+
@options = options
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def parse_type_predicate(type)
|
78
|
+
case type
|
79
|
+
when :String
|
80
|
+
proc { required('type').value(included_in?: %w[Symbol Text]) }
|
81
|
+
when :Int
|
82
|
+
proc { required('type').value(eql?: 'Integer') }
|
83
|
+
when :Float
|
84
|
+
proc { required('type').value(eql?: 'Number') }
|
85
|
+
when :DateTime
|
86
|
+
proc { required('type').value(eql?: 'Date') }
|
87
|
+
when :Asset
|
88
|
+
proc {
|
89
|
+
required('type').value(eql?: 'Link')
|
90
|
+
required('linkType').value(eql?: 'Asset')
|
91
|
+
}
|
92
|
+
else
|
93
|
+
proc { required('type').value(eql?: type.to_s.camelize) }
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def parse_option(option, option_arg = nil)
|
98
|
+
case option
|
99
|
+
when :required
|
100
|
+
proc { required('required').value(eql?: true) }
|
101
|
+
when :optional
|
102
|
+
proc { required('required').value(eql?: false) }
|
103
|
+
when :link_to
|
104
|
+
link_to_proc = parse_field_link_to(option_arg)
|
105
|
+
return link_to_proc unless @type.to_s.camelize == 'Array'
|
106
|
+
proc {
|
107
|
+
required('items').schema do
|
108
|
+
required('type').value(eql?: 'Link')
|
109
|
+
instance_eval(&link_to_proc)
|
110
|
+
end
|
111
|
+
}
|
112
|
+
when :items
|
113
|
+
type_pred = parse_type_predicate(option_arg)
|
114
|
+
proc {
|
115
|
+
required('items').schema do
|
116
|
+
instance_eval(&type_pred)
|
117
|
+
end
|
118
|
+
}
|
119
|
+
else
|
120
|
+
raise ArgumentError, "unknown validation requirement: #{option}"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def parse_field_link_to(option_arg)
|
125
|
+
raise ArgumentError, 'validation link_to: requires an argument' unless option_arg
|
126
|
+
|
127
|
+
# this works because a Link can only have one validation in its "validations" array -
|
128
|
+
# this will fail if Contentful ever changes that.
|
129
|
+
|
130
|
+
# the 'validations' schema needs to be optional because if we get the content
|
131
|
+
# types from the CDN instead of the management API, sometimes the validations
|
132
|
+
# don't get sent back.
|
133
|
+
|
134
|
+
# "validations": [
|
135
|
+
# {
|
136
|
+
# "linkContentType": [
|
137
|
+
# "section-CardSearch",
|
138
|
+
# "section-Faq",
|
139
|
+
# "section-Testimonials",
|
140
|
+
# "section-VideoHighlight"
|
141
|
+
# ]
|
142
|
+
# }
|
143
|
+
# ]
|
144
|
+
|
145
|
+
if option_arg.is_a?(Regexp)
|
146
|
+
return proc {
|
147
|
+
optional('validations').each do
|
148
|
+
schema do
|
149
|
+
required('linkContentType').each(format?: option_arg)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
}
|
153
|
+
end
|
154
|
+
|
155
|
+
option_arg = [option_arg] unless option_arg.is_a?(Array)
|
156
|
+
proc {
|
157
|
+
optional('validations').each do
|
158
|
+
schema do
|
159
|
+
required('linkContentType').value(eql?: option_arg)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
}
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'http'
|
4
|
+
|
5
|
+
require_relative 'simple_client/response'
|
6
|
+
|
7
|
+
module WCC::Contentful
|
8
|
+
class SimpleClient
|
9
|
+
def initialize(api_url:, space:, access_token:, **options)
|
10
|
+
@api_url = URI.join(api_url, '/spaces/', space + '/')
|
11
|
+
@space = space
|
12
|
+
@access_token = access_token
|
13
|
+
|
14
|
+
@get_http = options[:override_get_http] if options[:override_get_http].present?
|
15
|
+
|
16
|
+
@options = options
|
17
|
+
@query_defaults = {}
|
18
|
+
@query_defaults[:locale] = @options[:default_locale] if @options[:default_locale]
|
19
|
+
end
|
20
|
+
|
21
|
+
def get(path, query = {})
|
22
|
+
url = URI.join(@api_url, path)
|
23
|
+
|
24
|
+
Response.new(self,
|
25
|
+
{ url: url, query: query },
|
26
|
+
get_http(url, query))
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def get_http(url, query, headers = {}, proxy = {})
|
32
|
+
headers = {
|
33
|
+
Authorization: "Bearer #{@access_token}"
|
34
|
+
}.merge(headers || {})
|
35
|
+
|
36
|
+
q = @query_defaults.dup
|
37
|
+
q = q.merge(query) if query
|
38
|
+
|
39
|
+
resp =
|
40
|
+
if @get_http
|
41
|
+
@get_http.call(url, q, headers, proxy)
|
42
|
+
else
|
43
|
+
default_get_http(url, q, headers, proxy)
|
44
|
+
end
|
45
|
+
if [301, 302, 307].include?(resp.code) && !@options[:no_follow_redirects]
|
46
|
+
resp = get_http(resp.headers['location'], nil, headers, proxy)
|
47
|
+
end
|
48
|
+
resp
|
49
|
+
end
|
50
|
+
|
51
|
+
def default_get_http(url, query, headers = {}, proxy = {})
|
52
|
+
if proxy[:host]
|
53
|
+
HTTP[headers].via(proxy[:host], proxy[:port], proxy[:username], proxy[:password])
|
54
|
+
.get(url, params: query)
|
55
|
+
else
|
56
|
+
HTTP[headers].get(url, params: query)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class Cdn < SimpleClient
|
61
|
+
def initialize(space:, access_token:, **options)
|
62
|
+
super(
|
63
|
+
api_url: options[:api_url] || 'https://cdn.contentful.com/',
|
64
|
+
space: space,
|
65
|
+
access_token: access_token,
|
66
|
+
**options
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
def entry(key, query = {})
|
71
|
+
resp = get("entries/#{key}", query)
|
72
|
+
resp.assert_ok!
|
73
|
+
end
|
74
|
+
|
75
|
+
def entries(query = {})
|
76
|
+
resp = get('entries', query)
|
77
|
+
resp.assert_ok!
|
78
|
+
end
|
79
|
+
|
80
|
+
def asset(key, query = {})
|
81
|
+
resp = get("assets/#{key}", query)
|
82
|
+
resp.assert_ok!
|
83
|
+
end
|
84
|
+
|
85
|
+
def assets(query = {})
|
86
|
+
resp = get('assets', query)
|
87
|
+
resp.assert_ok!
|
88
|
+
end
|
89
|
+
|
90
|
+
def content_types(query = {})
|
91
|
+
resp = get('content_types', query)
|
92
|
+
resp.assert_ok!
|
93
|
+
end
|
94
|
+
|
95
|
+
def sync(sync_token: nil, **query)
|
96
|
+
sync_token =
|
97
|
+
if sync_token
|
98
|
+
{ sync_token: sync_token }
|
99
|
+
else
|
100
|
+
{ initial: true }
|
101
|
+
end
|
102
|
+
query = query.merge(sync_token)
|
103
|
+
resp = SyncResponse.new(get('sync', query))
|
104
|
+
resp.assert_ok!
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
class Management < SimpleClient
|
109
|
+
def initialize(management_token:, **options)
|
110
|
+
super(
|
111
|
+
api_url: options[:api_url] || 'https://api.contentful.com',
|
112
|
+
space: options[:space] || '/',
|
113
|
+
access_token: management_token,
|
114
|
+
**options
|
115
|
+
)
|
116
|
+
end
|
117
|
+
|
118
|
+
def content_types(space: nil, **query)
|
119
|
+
space ||= @space
|
120
|
+
raise ArgumentError, 'please provide a space ID' if space.nil?
|
121
|
+
|
122
|
+
resp = get("/spaces/#{space}/content_types", query)
|
123
|
+
resp.assert_ok!
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|