dead_simple_cms 0.9.0
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.
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +266 -0
- data/Rakefile +17 -0
- data/dead_simple_cms.gemspec +47 -0
- data/lib/dead_simple_cms.rb +132 -0
- data/lib/dead_simple_cms/attribute/collection.rb +45 -0
- data/lib/dead_simple_cms/attribute/type/all.rb +104 -0
- data/lib/dead_simple_cms/attribute/type/base.rb +79 -0
- data/lib/dead_simple_cms/attribute/type/collection_support.rb +26 -0
- data/lib/dead_simple_cms/configuration.rb +50 -0
- data/lib/dead_simple_cms/file_uploader/base.rb +29 -0
- data/lib/dead_simple_cms/group.rb +58 -0
- data/lib/dead_simple_cms/group/configuration.rb +32 -0
- data/lib/dead_simple_cms/group/presenter.rb +23 -0
- data/lib/dead_simple_cms/rails/action_controller/extensions.rb +24 -0
- data/lib/dead_simple_cms/rails/action_controller/fragment_sweeper.rb +19 -0
- data/lib/dead_simple_cms/rails/action_view/extensions.rb +14 -0
- data/lib/dead_simple_cms/rails/action_view/form_builders/default.rb +16 -0
- data/lib/dead_simple_cms/rails/action_view/form_builders/interface.rb +72 -0
- data/lib/dead_simple_cms/rails/action_view/form_builders/simple_form.rb +43 -0
- data/lib/dead_simple_cms/rails/action_view/form_builders/simple_form_with_bootstrap.rb +19 -0
- data/lib/dead_simple_cms/rails/action_view/presenter.rb +63 -0
- data/lib/dead_simple_cms/section.rb +103 -0
- data/lib/dead_simple_cms/section/builder.rb +73 -0
- data/lib/dead_simple_cms/storage/base.rb +49 -0
- data/lib/dead_simple_cms/storage/database.rb +33 -0
- data/lib/dead_simple_cms/storage/memory.rb +20 -0
- data/lib/dead_simple_cms/storage/rails_cache.rb +19 -0
- data/lib/dead_simple_cms/storage/redis.rb +23 -0
- data/lib/dead_simple_cms/util/identifier.rb +40 -0
- data/lib/dead_simple_cms/util/identifier/dictionary.rb +65 -0
- data/lib/dead_simple_cms/version.rb +3 -0
- data/spec/dead_simple_cms/attribute/collection_spec.rb +46 -0
- data/spec/dead_simple_cms/attribute/type/all_spec.rb +252 -0
- data/spec/dead_simple_cms/attribute/type/base_spec.rb +117 -0
- data/spec/dead_simple_cms/configuration_spec.rb +117 -0
- data/spec/dead_simple_cms/file_uploader/base_spec.rb +35 -0
- data/spec/dead_simple_cms/group/configuration_spec.rb +24 -0
- data/spec/dead_simple_cms/group/presenter_spec.rb +28 -0
- data/spec/dead_simple_cms/group_spec.rb +65 -0
- data/spec/dead_simple_cms/rails/action_view/form_builders/default_spec.rb +8 -0
- data/spec/dead_simple_cms/rails/action_view/form_builders/simple_form_spec.rb +8 -0
- data/spec/dead_simple_cms/rails/action_view/form_builders/simple_form_with_bootstrap_spec.rb +8 -0
- data/spec/dead_simple_cms/rails/action_view/fragment_sweeper_spec.rb +22 -0
- data/spec/dead_simple_cms/rails/action_view/presenter_spec.rb +54 -0
- data/spec/dead_simple_cms/section/builder_spec.rb +28 -0
- data/spec/dead_simple_cms/section_spec.rb +78 -0
- data/spec/dead_simple_cms/storage/base_spec.rb +59 -0
- data/spec/dead_simple_cms/storage/database_spec.rb +51 -0
- data/spec/dead_simple_cms/storage/memory_spec.rb +25 -0
- data/spec/dead_simple_cms/storage/rails_cache_spec.rb +33 -0
- data/spec/dead_simple_cms/storage/redis_spec.rb +33 -0
- data/spec/dead_simple_cms/util/identifier/dictionary/duplciate_item_spec.rb +10 -0
- data/spec/dead_simple_cms/util/identifier/dictionary/invalid_entry_class_spec.rb +10 -0
- data/spec/dead_simple_cms/util/identifier/dictionary_spec.rb +42 -0
- data/spec/dead_simple_cms/util/identifier_spec.rb +27 -0
- data/spec/setup.rb +26 -0
- data/spec/setup/banner_presenter.rb +19 -0
- data/spec/setup/rspec_template_builder.rb +104 -0
- data/spec/setup/shared.rb +57 -0
- data/spec/setup/test_file_uploader.rb +12 -0
- data/spec/spec_helper.rb +48 -0
- metadata +221 -0
@@ -0,0 +1,45 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
module Attribute
|
3
|
+
class Collection
|
4
|
+
include Util::Identifier
|
5
|
+
|
6
|
+
attr_reader :attributes
|
7
|
+
|
8
|
+
delegate :[], :[]=, :to => :attributes
|
9
|
+
|
10
|
+
# The method used to construct the identifier (key) by which the attribute (value) will be stored with.
|
11
|
+
class_attribute :dictionary_identifier_method
|
12
|
+
self.dictionary_identifier_method = :identifier
|
13
|
+
|
14
|
+
def initialize(identifier, options={})
|
15
|
+
@attributes = Attribute::Type::Base.new_dictionary(:identifier_method => dictionary_identifier_method)
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
# Play nicely with Rails fields_for.
|
20
|
+
def persisted?
|
21
|
+
false
|
22
|
+
end
|
23
|
+
|
24
|
+
def update_attributes(attributes)
|
25
|
+
attributes.each { |k, v| send("#{k}=", v) }
|
26
|
+
end
|
27
|
+
|
28
|
+
def add_attribute(attribute)
|
29
|
+
attributes.add(attribute)
|
30
|
+
attribute_accessor(attribute)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def attribute_accessor(attribute)
|
36
|
+
identifier = attribute.send(dictionary_identifier_method)
|
37
|
+
instance_eval <<-RUBY, __FILE__, __LINE__ + 1
|
38
|
+
def #{identifier} ; attributes[#{identifier.inspect}].value ; end
|
39
|
+
def #{identifier}=(v) ; attributes[#{identifier.inspect}].value = v ; end
|
40
|
+
RUBY
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
module Attribute
|
3
|
+
module Type
|
4
|
+
class String < Base
|
5
|
+
self.default_input_type = :string
|
6
|
+
include CollectionSupport
|
7
|
+
end
|
8
|
+
class Text < String
|
9
|
+
self.default_input_type = :text
|
10
|
+
end
|
11
|
+
class Symbol < Base
|
12
|
+
self.default_input_type = :string
|
13
|
+
def convert_value(value)
|
14
|
+
value.to_s.to_sym if value.present?
|
15
|
+
end
|
16
|
+
private :convert_value
|
17
|
+
end
|
18
|
+
class Boolean < Base
|
19
|
+
self.default_input_type = :radio
|
20
|
+
|
21
|
+
include CollectionSupport
|
22
|
+
|
23
|
+
def initialize(identifier, options={})
|
24
|
+
options.update(:collection => [true, false], :default => false)
|
25
|
+
super
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def convert_value(value)
|
31
|
+
value.is_a?(::String) ? ["true", "1"].include?(value.downcase) : !!value
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
class Numeric < Base
|
36
|
+
self.default_input_type = :string
|
37
|
+
include CollectionSupport
|
38
|
+
end
|
39
|
+
class Integer < Numeric
|
40
|
+
def convert_value(value)
|
41
|
+
value && value.to_i
|
42
|
+
end
|
43
|
+
end
|
44
|
+
# Public: File attributes are stored at some publicly accessible url.
|
45
|
+
class File < Base
|
46
|
+
self.default_input_type = :file
|
47
|
+
|
48
|
+
attr_accessor :data, :file_ext
|
49
|
+
|
50
|
+
class_attribute :uploader_class
|
51
|
+
|
52
|
+
alias :url :value
|
53
|
+
|
54
|
+
def initialize(identifier, options={})
|
55
|
+
@data = options[:data]
|
56
|
+
@file_ext = options[:file_ext] || "dat"
|
57
|
+
super
|
58
|
+
end
|
59
|
+
|
60
|
+
# Public: Takes the current #value, detects if its an object to upload and replaces the #value with the url
|
61
|
+
# to be stored in the CMS.
|
62
|
+
def upload!
|
63
|
+
raise NotImplementedError, "Please define an uploader class (see DeadSimpleCMS::FileUploader::Base)" unless uploader_class
|
64
|
+
return unless data
|
65
|
+
|
66
|
+
s3_uploader = uploader_class.new(self)
|
67
|
+
s3_uploader.upload!
|
68
|
+
self.value = s3_uploader.url
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def convert_value(value)
|
74
|
+
case value
|
75
|
+
when ::String, NilClass
|
76
|
+
value
|
77
|
+
when ActionDispatch::Http::UploadedFile
|
78
|
+
@file_ext = value.original_filename[/\.(.+)$/, 1]
|
79
|
+
value.rewind # make sure it's rewound
|
80
|
+
self.data = value.read
|
81
|
+
@value # just return the same stored @value so it doesn't change for now
|
82
|
+
else
|
83
|
+
raise("Don't know how to convert value: #{value.inspect}")
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
class Image < File
|
89
|
+
|
90
|
+
attr_reader :width, :height
|
91
|
+
|
92
|
+
def initialize(identifier, options={})
|
93
|
+
@width, @height = options.values_at(:width, :height)
|
94
|
+
super
|
95
|
+
end
|
96
|
+
|
97
|
+
def hint
|
98
|
+
super || "Image should be #{width} x #{height}."
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
module Attribute
|
3
|
+
module Type
|
4
|
+
class Base
|
5
|
+
|
6
|
+
include Util::Identifier
|
7
|
+
|
8
|
+
class << self
|
9
|
+
# Public: the method name used to identify this type in the Builder
|
10
|
+
attr_writer :builder_method_name
|
11
|
+
|
12
|
+
# If not provided on the subclass infer it from the class name. Because of how class_attribute works, this
|
13
|
+
# method will be overwritten when someone explicitly calls .builder_method_name= on a subclass.
|
14
|
+
def builder_method_name
|
15
|
+
@builder_method_name ||= name.demodulize.underscore
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: a Symbol representing the default input type required for forms
|
21
|
+
class_attribute :default_input_type, :instance_writer => false
|
22
|
+
|
23
|
+
VALID_INPUT_TYPES = [:string, :text, :select, :file, :radio].freeze
|
24
|
+
|
25
|
+
|
26
|
+
attr_reader :hint, :input_type, :group_hierarchy, :required
|
27
|
+
attr_accessor :section
|
28
|
+
|
29
|
+
def initialize(identifier, options={})
|
30
|
+
options.reverse_merge!(:group_hierarchy => [], :input_type => default_input_type, :required => false)
|
31
|
+
@hint, @default, @input_type, @group_hierarchy, @section, @required =
|
32
|
+
options.values_at(:hint, :default, :input_type, :group_hierarchy, :section, :required)
|
33
|
+
raise("Invalid input type: #{input_type.inspect}. Should be one of #{VALID_INPUT_TYPES}.") unless VALID_INPUT_TYPES.include?(input_type)
|
34
|
+
super
|
35
|
+
end
|
36
|
+
|
37
|
+
def root_group?
|
38
|
+
group_hierarchy.last.try(:root?)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Public: The identifier on the section level. It must be unique amongst the groups.
|
42
|
+
def section_identifier
|
43
|
+
(group_hierarchy + [self]).map(&:identifier).join("_").to_sym
|
44
|
+
end
|
45
|
+
|
46
|
+
def default
|
47
|
+
@default.is_a?(Proc) ? @default.call : @default
|
48
|
+
end
|
49
|
+
|
50
|
+
def value=(value)
|
51
|
+
@value = convert_value(value)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Public: Returns the non-blank value from the storage or the default.
|
55
|
+
def value
|
56
|
+
return @value if instance_variable_defined?(:@value) # If the value was set to nil, we should return that value.
|
57
|
+
attributes = attributes_from_storage
|
58
|
+
@value = attributes.key?(section_identifier) ? attributes[section_identifier] : default
|
59
|
+
end
|
60
|
+
|
61
|
+
def inspect
|
62
|
+
ivars = [:identifier, :hint, :default, :required, :input_type].map { |m| ivar = "@#{m}" ; "#{ivar}=#{instance_variable_get(ivar).inspect}" }
|
63
|
+
"#<#{self.class} #{ivars.join(", ")}"
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def attributes_from_storage
|
69
|
+
section.storage.read
|
70
|
+
end
|
71
|
+
|
72
|
+
def convert_value(value)
|
73
|
+
value.presence
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
module Attribute
|
3
|
+
module Type
|
4
|
+
module CollectionSupport
|
5
|
+
|
6
|
+
VALID_COLLECTION_INPUT_TYPES = [:select, :radio].freeze
|
7
|
+
|
8
|
+
DEFAULT_COLLECTION_INPUT_TYPE = :select
|
9
|
+
|
10
|
+
def initialize(identifier, options={})
|
11
|
+
if @collection = options[:collection]
|
12
|
+
# Pick either the options[:input_type], default_input_type, or the :select. Whichever matches the valid types.
|
13
|
+
options[:input_type] = ([options[:input_type], default_input_type, DEFAULT_COLLECTION_INPUT_TYPE] & VALID_COLLECTION_INPUT_TYPES).first
|
14
|
+
end
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
# Public: For performance and loading reasons, we allow the ability to pass through a collection as a lambda.
|
19
|
+
def collection
|
20
|
+
@collection.is_a?(Proc) ? @collection.call : @collection
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
class Configuration
|
3
|
+
|
4
|
+
def section(identifier, options={}, &block)
|
5
|
+
section = DeadSimpleCMS::Section.new(identifier, options, &block)
|
6
|
+
DeadSimpleCMS.sections.add(section)
|
7
|
+
# Convenience method, allows one to access DeadSimpleCMS.sections.<section_name>
|
8
|
+
DeadSimpleCMS.sections.instance_eval %{def #{section.identifier} ; self[#{section.identifier.inspect}] ; end}
|
9
|
+
end
|
10
|
+
|
11
|
+
def group_configuration(identifier, options={}, &block)
|
12
|
+
DeadSimpleCMS.group_configurations.add(Group::Configuration.new(identifier, options, &block))
|
13
|
+
end
|
14
|
+
|
15
|
+
def register_attribute_classes(*classes)
|
16
|
+
classes.each do |klass|
|
17
|
+
DeadSimpleCMS::Section::Builder.define_attribute_builder_method(klass)
|
18
|
+
Group::Configuration.define_attribute_builder_method(klass)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def storage_class(klass=nil, options={})
|
23
|
+
if klass
|
24
|
+
klass = "DeadSimpleCMS::Storage::#{klass.to_s.classify}".constantize if klass.is_a?(Symbol)
|
25
|
+
options.each { |k, v| klass.send("#{k}=", v) }
|
26
|
+
Section.storage_class = klass
|
27
|
+
end
|
28
|
+
Section.storage_class
|
29
|
+
end
|
30
|
+
|
31
|
+
def storage_serializer_class(klass=nil)
|
32
|
+
Storage::Base.serializer_class = klass if klass
|
33
|
+
Storage::Base.serializer_class
|
34
|
+
end
|
35
|
+
|
36
|
+
def default_form_builder(klass=nil)
|
37
|
+
if klass
|
38
|
+
klass = "DeadSimpleCMS::Rails::ActionView::FormBuilders::#{klass.to_s.classify}".constantize if klass.is_a?(Symbol)
|
39
|
+
DeadSimpleCMS::Rails::ActionView::Presenter.form_builder = klass
|
40
|
+
end
|
41
|
+
DeadSimpleCMS::Rails::ActionView::Presenter.form_builder
|
42
|
+
end
|
43
|
+
|
44
|
+
def file_uploader_class(klass=nil)
|
45
|
+
Attribute::Type::File.uploader_class = klass if klass
|
46
|
+
Attribute::Type::File.uploader_class
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
module FileUploader
|
3
|
+
# Base file uploader class. Inherit from this to build your own uploaders for files.
|
4
|
+
class Base
|
5
|
+
|
6
|
+
attr_reader :file_attribute
|
7
|
+
delegate :file_ext, :data, :to => :file_attribute
|
8
|
+
|
9
|
+
def initialize(file_attribute)
|
10
|
+
@file_attribute = file_attribute
|
11
|
+
end
|
12
|
+
|
13
|
+
def upload!
|
14
|
+
raise NotImplementedError, "Please overwrite this with your own upload functionality."
|
15
|
+
end
|
16
|
+
|
17
|
+
def url
|
18
|
+
raise NotImplementedError, "Please overwrite this with your own url constructor."
|
19
|
+
end
|
20
|
+
|
21
|
+
# Public: We need to have all these nesting to protect against corner-case collisons.
|
22
|
+
def path(namespace="dead_simple_cms")
|
23
|
+
# dead_simple_cms/<section>/<group>/attribute.jpg
|
24
|
+
@path ||= [namespace, *[file_attribute.section, *file_attribute.group_hierarchy, file_attribute].map(&:identifier)].compact * "/" + "." + file_ext
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
# Public: A Group is essentially an Attribute::Collection with the ability to have it's own groups on it with infinite
|
3
|
+
# nesting of groups.
|
4
|
+
class Group < Attribute::Collection
|
5
|
+
|
6
|
+
ROOT_IDENTIFIER = :root
|
7
|
+
|
8
|
+
attr_reader :groups, :presenter_class, :render_proc
|
9
|
+
|
10
|
+
def initialize(identifier, options={})
|
11
|
+
@groups = DeadSimpleCMS::Group.new_dictionary
|
12
|
+
super
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.root
|
16
|
+
new(ROOT_IDENTIFIER)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Public: Set different mechanisms for rendering this group.
|
20
|
+
def display(presenter_class=nil, &block)
|
21
|
+
@presenter_class = presenter_class
|
22
|
+
@render_proc = block
|
23
|
+
end
|
24
|
+
|
25
|
+
# Public: If a presenter class was specified, returns an instance of the presenter.
|
26
|
+
def presenter(view_context, *args)
|
27
|
+
@presenter_class.new(view_context, *args) if @presenter_class
|
28
|
+
end
|
29
|
+
|
30
|
+
# Public: Render the group using the passed in proc in the scope of the template.
|
31
|
+
def render(view_context, *args)
|
32
|
+
if @render_proc
|
33
|
+
view_context.instance_exec(self, *args, &@render_proc)
|
34
|
+
elsif presenter = presenter(view_context, self, *args)
|
35
|
+
presenter.render
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def root?
|
40
|
+
identifier == ROOT_IDENTIFIER
|
41
|
+
end
|
42
|
+
|
43
|
+
def add_group(group)
|
44
|
+
groups.add(group)
|
45
|
+
group_accessor(group)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# The <group>_attributes method plays nice with form builder's fields_for.
|
51
|
+
def group_accessor(group)
|
52
|
+
instance_eval <<-RUBY, __FILE__, __LINE__ + 1
|
53
|
+
def #{group.identifier}_attributes=(v) ; groups[#{group.identifier.inspect}].update_attributes(v) ; end
|
54
|
+
def #{group.identifier} ; groups[#{group.identifier.inspect}] ; end
|
55
|
+
RUBY
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module DeadSimpleCMS
|
2
|
+
class Group
|
3
|
+
class Configuration
|
4
|
+
|
5
|
+
include Util::Identifier
|
6
|
+
|
7
|
+
attr_reader :options, :attribute_arguments, :presenter_class, :render_proc
|
8
|
+
|
9
|
+
def initialize(identifier, options={}, &block)
|
10
|
+
super(identifier, options)
|
11
|
+
@options = options
|
12
|
+
@attribute_arguments = {}
|
13
|
+
instance_eval(&block)
|
14
|
+
end
|
15
|
+
|
16
|
+
def display(presenter_class=nil, &block)
|
17
|
+
@presenter_class = presenter_class if presenter_class
|
18
|
+
@render_proc = block if block_given?
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.define_attribute_builder_method(klass)
|
22
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
23
|
+
def #{klass.builder_method_name}(identifier, options={})
|
24
|
+
attribute_arguments[identifier] = [#{klass.builder_method_name.inspect}, options]
|
25
|
+
end
|
26
|
+
RUBY
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
module DeadSimpleCMS
|
3
|
+
class Group
|
4
|
+
# Public: Presenter class used for rendering groups.
|
5
|
+
#
|
6
|
+
# Within the context of this group, you can call methods as if you were in the view_context.
|
7
|
+
class Presenter < SimpleDelegator
|
8
|
+
|
9
|
+
attr_reader :group
|
10
|
+
|
11
|
+
def initialize(view_context, group, *args)
|
12
|
+
@group = group
|
13
|
+
initialize_extra_arguments(*args)
|
14
|
+
super(view_context)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Private: Initialize extra arguments for the presenter.
|
18
|
+
def initialize_extra_arguments(*args)
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|