docit 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/README.md +475 -21
- data/TROUBLESHOOTING.md +57 -0
- data/app/controllers/docit/ui_controller.rb +23 -3
- data/config/routes.rb +2 -0
- data/docs/images/scalar_image.png +0 -0
- data/docs/images/swagger_image.png +0 -0
- data/lib/docit/ai/autodoc_runner.rb +9 -4
- data/lib/docit/ai/doc_writer.rb +3 -3
- data/lib/docit/ai/groq_client.rb +4 -8
- data/lib/docit/ai/scaffold_generator.rb +3 -1
- data/lib/docit/ai/tag_injector.rb +1 -1
- data/lib/docit/builders/response_builder.rb +12 -2
- data/lib/docit/configuration.rb +5 -4
- data/lib/docit/schema_generator.rb +49 -31
- data/lib/docit/system_graph/edge.rb +29 -0
- data/lib/docit/system_graph/generator.rb +15 -0
- data/lib/docit/system_graph/graph.rb +55 -0
- data/lib/docit/system_graph/node.rb +32 -0
- data/lib/docit/system_graph/rails_analyzer.rb +278 -0
- data/lib/docit/system_graph/source_scanner.rb +76 -0
- data/lib/docit/system_graph.rb +8 -0
- data/lib/docit/ui/base_renderer.rb +49 -21
- data/lib/docit/ui/system_renderer.rb +113 -0
- data/lib/docit/ui/system_script.rb +1416 -0
- data/lib/docit/ui/system_styles.rb +566 -0
- data/lib/docit/version.rb +1 -1
- data/lib/docit.rb +4 -0
- data/lib/generators/docit/install/templates/initializer.rb +4 -0
- metadata +17 -4
|
@@ -16,6 +16,17 @@ module Docit
|
|
|
16
16
|
render_ui(:scalar)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def system
|
|
20
|
+
render_ui(:system)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def system_spec
|
|
24
|
+
return render(json: { error: "System graph disabled" }, status: :not_found) unless Docit.configuration.system_graph_enabled
|
|
25
|
+
|
|
26
|
+
RouteInspector.eager_load_controllers!
|
|
27
|
+
render json: SystemGraph::Generator.generate
|
|
28
|
+
end
|
|
29
|
+
|
|
19
30
|
def spec
|
|
20
31
|
RouteInspector.eager_load_controllers!
|
|
21
32
|
render json: SchemaGenerator.generate
|
|
@@ -25,11 +36,16 @@ module Docit
|
|
|
25
36
|
|
|
26
37
|
RENDERERS = {
|
|
27
38
|
swagger: UI::SwaggerRenderer,
|
|
28
|
-
scalar: UI::ScalarRenderer
|
|
39
|
+
scalar: UI::ScalarRenderer,
|
|
40
|
+
system: UI::SystemRenderer
|
|
29
41
|
}.freeze
|
|
30
42
|
|
|
31
43
|
def render_ui(ui_name)
|
|
32
|
-
renderer = RENDERERS.fetch(ui_name).new(
|
|
44
|
+
renderer = RENDERERS.fetch(ui_name).new(
|
|
45
|
+
spec_url: spec_url,
|
|
46
|
+
system_url: system_url,
|
|
47
|
+
nav_paths: nav_paths
|
|
48
|
+
)
|
|
33
49
|
render html: renderer.render.html_safe, layout: false
|
|
34
50
|
end
|
|
35
51
|
|
|
@@ -37,9 +53,13 @@ module Docit
|
|
|
37
53
|
"#{request.base_url}#{Docit::Engine.routes.url_helpers.spec_path}"
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
def system_url
|
|
57
|
+
"#{request.base_url}#{Docit::Engine.routes.url_helpers.system_spec_path}"
|
|
58
|
+
end
|
|
59
|
+
|
|
40
60
|
def nav_paths
|
|
41
61
|
helpers = Docit::Engine.routes.url_helpers
|
|
42
|
-
{ swagger: helpers.swagger_path, scalar: helpers.scalar_path }
|
|
62
|
+
{ swagger: helpers.swagger_path, scalar: helpers.scalar_path, system: helpers.system_path }
|
|
43
63
|
end
|
|
44
64
|
end
|
|
45
65
|
end
|
data/config/routes.rb
CHANGED
|
@@ -4,5 +4,7 @@ Docit::Engine.routes.draw do
|
|
|
4
4
|
root to: "ui#index"
|
|
5
5
|
get "swagger", to: "ui#swagger"
|
|
6
6
|
get "scalar", to: "ui#scalar"
|
|
7
|
+
get "system.json", to: "ui#system_spec", defaults: { format: :json }, as: :system_spec
|
|
8
|
+
get "system", to: "ui#system", as: :system
|
|
7
9
|
get "spec", to: "ui#spec", defaults: { format: :json }
|
|
8
10
|
end
|
|
Binary file
|
|
Binary file
|
|
@@ -57,9 +57,7 @@ module Docit
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
initializer = Rails.root.join("config", "initializers", "docit.rb")
|
|
60
|
-
if File.exist?(initializer) == false
|
|
61
|
-
raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
|
|
62
|
-
end
|
|
60
|
+
raise Docit::Error, "Docit is not installed. Run: rails generate docit:install" if File.exist?(initializer) == false
|
|
63
61
|
|
|
64
62
|
routes_file = Rails.root.join("config", "routes.rb")
|
|
65
63
|
return unless File.exist?(routes_file) && !File.read(routes_file).include?("Docit::Engine")
|
|
@@ -179,8 +177,15 @@ module Docit
|
|
|
179
177
|
end
|
|
180
178
|
end
|
|
181
179
|
|
|
180
|
+
endpoints = pluralize(@results[:generated], "endpoint")
|
|
181
|
+
files = pluralize(@results[:files].length, "file")
|
|
182
182
|
@output.puts ""
|
|
183
|
-
@output.puts "Generated docs for #{
|
|
183
|
+
@output.puts "Generated docs for #{endpoints} in #{files}."
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# "1 endpoint" / "2 endpoints" — keeps the summary lines short and readable.
|
|
187
|
+
def pluralize(count, noun)
|
|
188
|
+
"#{count} #{noun}#{"s" unless count == 1}"
|
|
184
189
|
end
|
|
185
190
|
|
|
186
191
|
def inject_tags(generated)
|
data/lib/docit/ai/doc_writer.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Docit
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def doc_module_name
|
|
23
|
-
@controller_name.delete_suffix("Controller").gsub("::", "::")
|
|
23
|
+
"#{@controller_name.delete_suffix("Controller").gsub("::", "::")}Docs"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def file_exists?
|
|
@@ -44,7 +44,7 @@ module Docit
|
|
|
44
44
|
match = content.match(class_pattern)
|
|
45
45
|
return false if match.nil?
|
|
46
46
|
|
|
47
|
-
indent = match[1][/^\s*/]
|
|
47
|
+
indent = "#{match[1][/^\s*/]} "
|
|
48
48
|
use_docs_line = "#{indent}use_docs #{doc_module_name}\n"
|
|
49
49
|
content = content.sub(class_pattern, "\\1\n#{use_docs_line}")
|
|
50
50
|
|
|
@@ -100,7 +100,7 @@ module Docit
|
|
|
100
100
|
lines << "#{" " * (@module_parts.length - 1 - i)}end"
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
lines.join("\n")
|
|
103
|
+
"#{lines.join("\n")}\n"
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def indent_block(block, depth)
|
data/lib/docit/ai/groq_client.rb
CHANGED
|
@@ -59,18 +59,14 @@ module Docit
|
|
|
59
59
|
|
|
60
60
|
def parse_retry_after(response, message)
|
|
61
61
|
# Check Retry-After header first (seconds)
|
|
62
|
-
if (header = response["Retry-After"])
|
|
63
|
-
return header.to_f
|
|
62
|
+
if (header = response["Retry-After"]) && header.to_f.positive?
|
|
63
|
+
return header.to_f
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Parse "try again in XmY.Zs" from error message
|
|
67
|
-
if message =~ /(\d+)m([\d.]+)s/
|
|
68
|
-
return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f
|
|
69
|
-
end
|
|
67
|
+
return (Regexp.last_match(1).to_i * 60) + Regexp.last_match(2).to_f if message =~ /(\d+)m([\d.]+)s/
|
|
70
68
|
|
|
71
|
-
if message =~ /([\d.]+)s/
|
|
72
|
-
return Regexp.last_match(1).to_f
|
|
73
|
-
end
|
|
69
|
+
return Regexp.last_match(1).to_f if message =~ /([\d.]+)s/
|
|
74
70
|
|
|
75
71
|
nil
|
|
76
72
|
end
|
|
@@ -50,8 +50,10 @@ module Docit
|
|
|
50
50
|
|
|
51
51
|
inject_tags(grouped)
|
|
52
52
|
|
|
53
|
+
endpoints = "endpoint".pluralize(gaps.length)
|
|
54
|
+
files = "file".pluralize(@files_written.length)
|
|
53
55
|
@output.puts ""
|
|
54
|
-
@output.puts "Scaffolded #{gaps.length}
|
|
56
|
+
@output.puts "Scaffolded #{gaps.length} #{endpoints} in #{@files_written.length} #{files}."
|
|
55
57
|
@output.puts "Fill in the TODO placeholders in your doc files."
|
|
56
58
|
@files_written
|
|
57
59
|
end
|
|
@@ -24,7 +24,7 @@ module Docit
|
|
|
24
24
|
insertion_point = find_insertion_point(content)
|
|
25
25
|
return [] if insertion_point.nil?
|
|
26
26
|
|
|
27
|
-
content
|
|
27
|
+
content.insert(insertion_point, "\n#{lines.join("\n")}")
|
|
28
28
|
File.write(initializer_path, content)
|
|
29
29
|
|
|
30
30
|
new_tags
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
module Docit
|
|
4
4
|
module Builders
|
|
5
5
|
# Builds the schema for a single HTTP response, including properties,
|
|
6
|
-
# examples, and schema references.
|
|
6
|
+
# examples, headers, and schema references.
|
|
7
7
|
class ResponseBuilder
|
|
8
|
-
attr_reader :status, :description, :properties, :examples, :schema_ref
|
|
8
|
+
attr_reader :status, :description, :properties, :examples, :headers, :schema_ref
|
|
9
9
|
|
|
10
10
|
def initialize(status:, description:)
|
|
11
11
|
@status = status
|
|
12
12
|
@description = description
|
|
13
13
|
@properties = []
|
|
14
14
|
@examples = []
|
|
15
|
+
@headers = []
|
|
15
16
|
@schema_ref = nil
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -19,6 +20,15 @@ module Docit
|
|
|
19
20
|
@schema_ref = ref.to_sym
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
# Declares a response header, e.g. a rate-limit or pagination header:
|
|
24
|
+
# header "X-RateLimit-Remaining", type: :integer, description: "..."
|
|
25
|
+
def header(name, type: :string, description: nil, example: nil)
|
|
26
|
+
entry = { name: name.to_s, type: type.to_s }
|
|
27
|
+
entry[:description] = description if description
|
|
28
|
+
entry[:example] = example unless example.nil?
|
|
29
|
+
@headers << entry
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
def property(name, type:, format: nil, example: nil, enum: nil, description: nil, items: nil, **opts, &block)
|
|
23
33
|
prop = { name: name, type: type }
|
|
24
34
|
prop[:format] = format if format
|
data/lib/docit/configuration.rb
CHANGED
|
@@ -5,7 +5,8 @@ module Docit
|
|
|
5
5
|
class Configuration
|
|
6
6
|
SUPPORTED_UIS = %i[scalar swagger].freeze
|
|
7
7
|
|
|
8
|
-
attr_accessor :title, :version, :description, :base_url
|
|
8
|
+
attr_accessor :title, :version, :description, :base_url,
|
|
9
|
+
:system_graph_enabled, :system_graph_excluded_paths
|
|
9
10
|
attr_reader :default_ui
|
|
10
11
|
|
|
11
12
|
def initialize
|
|
@@ -13,6 +14,8 @@ module Docit
|
|
|
13
14
|
@version = "1.0.0"
|
|
14
15
|
@description = "Welcome to the API documentation. Browse the endpoints below to get started."
|
|
15
16
|
@base_url = "/"
|
|
17
|
+
@system_graph_enabled = true
|
|
18
|
+
@system_graph_excluded_paths = []
|
|
16
19
|
@default_ui = :scalar
|
|
17
20
|
@security_schemes = {}
|
|
18
21
|
@tags = []
|
|
@@ -24,9 +27,7 @@ module Docit
|
|
|
24
27
|
|
|
25
28
|
def default_ui=(value)
|
|
26
29
|
ui = value.to_sym
|
|
27
|
-
unless SUPPORTED_UIS.include?(ui)
|
|
28
|
-
raise ArgumentError, "Unsupported UI: #{value}. Must be one of: #{SUPPORTED_UIS.join(", ")}"
|
|
29
|
-
end
|
|
30
|
+
raise ArgumentError, "Unsupported UI: #{value}. Must be one of: #{SUPPORTED_UIS.join(", ")}" unless SUPPORTED_UIS.include?(ui)
|
|
30
31
|
|
|
31
32
|
@default_ui = ui
|
|
32
33
|
end
|
|
@@ -104,10 +104,21 @@ module Docit
|
|
|
104
104
|
entry[:content] = content
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
entry[:headers] = build_response_headers(builder.headers) if builder.headers.any?
|
|
108
|
+
|
|
107
109
|
hash[builder.status.to_s] = entry
|
|
108
110
|
end
|
|
109
111
|
end
|
|
110
112
|
|
|
113
|
+
def build_response_headers(headers)
|
|
114
|
+
headers.each_with_object({}) do |header, hash|
|
|
115
|
+
schema = { schema: { type: header[:type] } }
|
|
116
|
+
schema[:description] = header[:description] if header[:description]
|
|
117
|
+
schema[:schema][:example] = header[:example] if header.key?(:example)
|
|
118
|
+
hash[header[:name]] = schema
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
111
122
|
def build_request_body(request_body_builder)
|
|
112
123
|
if request_body_builder.schema_ref
|
|
113
124
|
schema = { "$ref" => "#/components/schemas/#{request_body_builder.schema_ref}" }
|
|
@@ -138,43 +149,50 @@ module Docit
|
|
|
138
149
|
|
|
139
150
|
def build_property_schema(prop)
|
|
140
151
|
type = prop[:type].to_s
|
|
152
|
+
schema = base_property_schema(type, prop)
|
|
153
|
+
|
|
154
|
+
# These OpenAPI fields apply to any property shape, so set them once here
|
|
155
|
+
# rather than in each branch above. nil-checks let `false`/`0` through as
|
|
156
|
+
# explicit values (e.g. default: false), but skip unset options.
|
|
157
|
+
schema[:description] = prop[:description] if prop[:description]
|
|
158
|
+
schema[:example] = prop[:example] if prop[:example]
|
|
159
|
+
schema[:default] = prop[:default] unless prop[:default].nil?
|
|
160
|
+
schema[:nullable] = prop[:nullable] unless prop[:nullable].nil?
|
|
161
|
+
schema[:readOnly] = prop[:read_only] unless prop[:read_only].nil?
|
|
162
|
+
schema[:writeOnly] = prop[:write_only] unless prop[:write_only].nil?
|
|
163
|
+
schema
|
|
164
|
+
end
|
|
141
165
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
166
|
+
def base_property_schema(type, prop)
|
|
167
|
+
case type
|
|
168
|
+
when "file"
|
|
169
|
+
{ type: "string", format: "binary" }
|
|
170
|
+
when "array"
|
|
171
|
+
{ type: "array", items: array_items_schema(prop) }
|
|
172
|
+
else
|
|
173
|
+
# An object with declared children becomes a nested schema; everything
|
|
174
|
+
# else (including a childless "object") is a scalar with type/format/enum.
|
|
175
|
+
if type == "object" && prop[:children]
|
|
176
|
+
{ type: "object", properties: build_properties(prop[:children]) }
|
|
153
177
|
else
|
|
154
|
-
|
|
155
|
-
schema[:items] = { type: items_type }
|
|
178
|
+
scalar_property_schema(type, prop)
|
|
156
179
|
end
|
|
157
|
-
schema[:description] = prop[:description] if prop[:description]
|
|
158
|
-
schema[:example] = prop[:example] if prop[:example]
|
|
159
|
-
schema
|
|
160
|
-
elsif type == "object" && prop[:children]
|
|
161
|
-
schema = {
|
|
162
|
-
type: "object",
|
|
163
|
-
properties: build_properties(prop[:children])
|
|
164
|
-
}
|
|
165
|
-
schema[:description] = prop[:description] if prop[:description]
|
|
166
|
-
schema[:example] = prop[:example] if prop[:example]
|
|
167
|
-
schema
|
|
168
|
-
else
|
|
169
|
-
schema = { type: type }
|
|
170
|
-
schema[:format] = prop[:format].to_s if prop[:format]
|
|
171
|
-
schema[:enum] = prop[:enum] if prop[:enum]
|
|
172
|
-
schema[:example] = prop[:example] if prop[:example]
|
|
173
|
-
schema[:description] = prop[:description] if prop[:description]
|
|
174
|
-
schema
|
|
175
180
|
end
|
|
176
181
|
end
|
|
177
182
|
|
|
183
|
+
def array_items_schema(prop)
|
|
184
|
+
return { type: "object", properties: build_properties(prop[:children]) } if prop[:children]
|
|
185
|
+
|
|
186
|
+
{ type: prop[:items]&.to_s || "string" }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def scalar_property_schema(type, prop)
|
|
190
|
+
schema = { type: type }
|
|
191
|
+
schema[:format] = prop[:format].to_s if prop[:format]
|
|
192
|
+
schema[:enum] = prop[:enum] if prop[:enum]
|
|
193
|
+
schema
|
|
194
|
+
end
|
|
195
|
+
|
|
178
196
|
def build_component_schemas
|
|
179
197
|
Docit.schemas.each_with_object({}) do |(name, definition), hash|
|
|
180
198
|
schema = {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module SystemGraph
|
|
5
|
+
class Edge
|
|
6
|
+
attr_reader :id, :source, :target, :type, :confidence, :evidence
|
|
7
|
+
|
|
8
|
+
def initialize(id:, source:, target:, type:, confidence:, evidence:)
|
|
9
|
+
@id = id.to_s
|
|
10
|
+
@source = source.to_s
|
|
11
|
+
@target = target.to_s
|
|
12
|
+
@type = type.to_s
|
|
13
|
+
@confidence = confidence.to_s
|
|
14
|
+
@evidence = evidence.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
id: id,
|
|
20
|
+
source: source,
|
|
21
|
+
target: target,
|
|
22
|
+
type: type,
|
|
23
|
+
confidence: confidence,
|
|
24
|
+
evidence: evidence
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Docit
|
|
6
|
+
module SystemGraph
|
|
7
|
+
class Graph
|
|
8
|
+
VERSION = "1.0"
|
|
9
|
+
|
|
10
|
+
attr_reader :nodes, :edges, :framework
|
|
11
|
+
|
|
12
|
+
def initialize(framework: "rails")
|
|
13
|
+
@framework = framework
|
|
14
|
+
@nodes = {}
|
|
15
|
+
@edges = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_node(node)
|
|
19
|
+
nodes[node.id] ||= node
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_edge(edge)
|
|
23
|
+
return if edge.source == edge.target
|
|
24
|
+
return unless nodes.key?(edge.source) && nodes.key?(edge.target)
|
|
25
|
+
|
|
26
|
+
edges[edge.id] ||= edge
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
node_values = nodes.values
|
|
31
|
+
edge_values = edges.values
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
version: VERSION,
|
|
35
|
+
generated_at: Time.now.utc.iso8601,
|
|
36
|
+
framework: framework,
|
|
37
|
+
nodes: node_values.map(&:to_h),
|
|
38
|
+
edges: edge_values.map(&:to_h),
|
|
39
|
+
stats: stats(node_values, edge_values)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def stats(node_values, edge_values)
|
|
46
|
+
{
|
|
47
|
+
nodes: node_values.length,
|
|
48
|
+
edges: edge_values.length,
|
|
49
|
+
node_types: node_values.group_by(&:type).transform_values(&:length),
|
|
50
|
+
edge_types: edge_values.group_by(&:type).transform_values(&:length)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docit
|
|
4
|
+
module SystemGraph
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :id, :type, :label, :metadata, :file, :line, :status
|
|
7
|
+
|
|
8
|
+
def initialize(id:, type:, label:, metadata: {}, file: nil, line: nil, status: nil)
|
|
9
|
+
@id = id.to_s
|
|
10
|
+
@type = type.to_s
|
|
11
|
+
@label = label.to_s
|
|
12
|
+
@metadata = metadata
|
|
13
|
+
@file = file
|
|
14
|
+
@line = line
|
|
15
|
+
@status = status
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
id: id,
|
|
21
|
+
type: type,
|
|
22
|
+
label: label,
|
|
23
|
+
metadata: metadata
|
|
24
|
+
}.tap do |hash|
|
|
25
|
+
hash[:file] = file if file
|
|
26
|
+
hash[:line] = line if line
|
|
27
|
+
hash[:status] = status if status
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|