command_proposal 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +77 -0
- data/Rakefile +18 -0
- data/app/assets/config/command_proposal_manifest.js +1 -0
- data/app/assets/javascripts/command_proposal/_codemirror.js +9814 -0
- data/app/assets/javascripts/command_proposal/_helpers.js +9 -0
- data/app/assets/javascripts/command_proposal/codemirror-addon-searchcursor.js +296 -0
- data/app/assets/javascripts/command_proposal/codemirror-keymap-sublime.js +720 -0
- data/app/assets/javascripts/command_proposal/codemirror-mode-ruby.js +303 -0
- data/app/assets/javascripts/command_proposal/console.js +195 -0
- data/app/assets/javascripts/command_proposal/feed.js +51 -0
- data/app/assets/javascripts/command_proposal/terminal.js +40 -0
- data/app/assets/javascripts/command_proposal.js +1 -0
- data/app/assets/stylesheets/command_proposal/_variables.scss +0 -0
- data/app/assets/stylesheets/command_proposal/codemirror-rubyblue.scss +27 -0
- data/app/assets/stylesheets/command_proposal/codemirror.scss +367 -0
- data/app/assets/stylesheets/command_proposal/command_proposal.scss +1 -0
- data/app/assets/stylesheets/command_proposal/components.scss +31 -0
- data/app/assets/stylesheets/command_proposal/containers.scss +4 -0
- data/app/assets/stylesheets/command_proposal/icons.scss +12 -0
- data/app/assets/stylesheets/command_proposal/tables.scss +76 -0
- data/app/assets/stylesheets/command_proposal/terminal.scss +72 -0
- data/app/assets/stylesheets/command_proposal.scss +5 -0
- data/app/controllers/command_proposal/engine_controller.rb +6 -0
- data/app/controllers/command_proposal/iterations_controller.rb +83 -0
- data/app/controllers/command_proposal/runner_controller.rb +86 -0
- data/app/controllers/command_proposal/tasks_controller.rb +97 -0
- data/app/helpers/command_proposal/application_helper.rb +58 -0
- data/app/helpers/command_proposal/icons_helper.rb +15 -0
- data/app/helpers/command_proposal/params_helper.rb +63 -0
- data/app/helpers/command_proposal/permissions_helper.rb +42 -0
- data/app/jobs/command_proposal/application_job.rb +4 -0
- data/app/jobs/command_proposal/command_runner_job.rb +11 -0
- data/app/models/command_proposal/comment.rb +14 -0
- data/app/models/command_proposal/iteration.rb +78 -0
- data/app/models/command_proposal/service/external_belong.rb +48 -0
- data/app/models/command_proposal/service/json_wrapper.rb +18 -0
- data/app/models/command_proposal/service/proposal_presenter.rb +39 -0
- data/app/models/command_proposal/task.rb +106 -0
- data/app/views/command_proposal/tasks/_console_show.html.erb +44 -0
- data/app/views/command_proposal/tasks/_function_show.html.erb +54 -0
- data/app/views/command_proposal/tasks/_lines.html.erb +8 -0
- data/app/views/command_proposal/tasks/_module_show.html.erb +33 -0
- data/app/views/command_proposal/tasks/_past_iterations_list.html.erb +20 -0
- data/app/views/command_proposal/tasks/_task_detail_table.html.erb +31 -0
- data/app/views/command_proposal/tasks/_task_show.html.erb +55 -0
- data/app/views/command_proposal/tasks/error.html.erb +4 -0
- data/app/views/command_proposal/tasks/form.html.erb +64 -0
- data/app/views/command_proposal/tasks/index.html.erb +44 -0
- data/app/views/command_proposal/tasks/show.html.erb +10 -0
- data/config/routes.rb +11 -0
- data/lib/command_proposal/configuration.rb +41 -0
- data/lib/command_proposal/engine.rb +6 -0
- data/lib/command_proposal/services/command_interpreter.rb +108 -0
- data/lib/command_proposal/services/runner.rb +157 -0
- data/lib/command_proposal/version.rb +3 -0
- data/lib/command_proposal.rb +27 -0
- data/lib/generators/command_proposal/install/install_generator.rb +28 -0
- data/lib/generators/command_proposal/install/templates/initializer.rb +47 -0
- data/lib/generators/command_proposal/install/templates/install_command_proposal.rb +40 -0
- data/lib/tasks/command_proposal_tasks.rake +4 -0
- metadata +167 -0
@@ -0,0 +1,58 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
module ApplicationHelper
|
3
|
+
# In order to keep the regular app's routes working in the base template, we have to manually
|
4
|
+
# render the engine routes. Built a helper for this because it's long and nasty otherwise.
|
5
|
+
def cmd_path(*args)
|
6
|
+
return string_path(*args) if args.first.is_a?(String)
|
7
|
+
model_names = [:tasks, :iterations, :comments, :task, :iteration, :comment]
|
8
|
+
host = nil
|
9
|
+
args.map! { |arg|
|
10
|
+
next host ||= arg.delete(:host) if arg.is_a?(Hash) && arg.key?(:host)
|
11
|
+
if arg.in?(model_names)
|
12
|
+
"command_proposal_#{arg}".to_sym
|
13
|
+
elsif arg == :task_iterations
|
14
|
+
:iterations
|
15
|
+
else
|
16
|
+
arg
|
17
|
+
end
|
18
|
+
}
|
19
|
+
args << { host: host, port: nil } if host.present?
|
20
|
+
|
21
|
+
begin
|
22
|
+
router.url_for(args.compact)
|
23
|
+
rescue NoMethodError => e
|
24
|
+
raise "Error generating route! Please make sure `default_url_options` are set."
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def string_path(*args)
|
29
|
+
[router.command_proposal_tasks_url + args.shift, args.to_param.presence].compact.join("?")
|
30
|
+
end
|
31
|
+
|
32
|
+
# Runner controller doesn't map to a model, so needs special handling
|
33
|
+
def runner_path(task, iteration=nil)
|
34
|
+
if iteration.present?
|
35
|
+
router.command_proposal_task_runner_url(task, iteration)
|
36
|
+
else
|
37
|
+
router.command_proposal_task_runner_index_url(task)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def router
|
42
|
+
@@router ||= begin
|
43
|
+
routes = ::CommandProposal::Engine.routes
|
44
|
+
routes.default_url_options = rails_default_url_options
|
45
|
+
routes.url_helpers
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def rails_default_url_options
|
50
|
+
Rails.application.config.action_mailer.default_url_options.tap do |default_opts|
|
51
|
+
default_opts ||= {}
|
52
|
+
default_opts[:host] ||= "localhost"
|
53
|
+
default_opts[:port] ||= "3000"
|
54
|
+
default_opts[:protocol] ||= "http"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
module IconsHelper
|
3
|
+
def icon(status)
|
4
|
+
{
|
5
|
+
created: "cmd-icon-grey fa fa-check",
|
6
|
+
approved: "cmd-icon-green fa fa-check",
|
7
|
+
started: "cmd-icon-grey fa fa-clock-o",
|
8
|
+
cancelling: "cmd-icon-yellow fa fa-hourglass-half",
|
9
|
+
cancelled: "cmd-icon-yellow fa fa-stop-circle",
|
10
|
+
failed: "cmd-icon-red fa fa-times-circle",
|
11
|
+
success: "cmd-icon-green fa fa-check-circle",
|
12
|
+
}[status&.to_sym] || "cmd-icon-yellow fa fa-question"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module ::CommandProposal::ParamsHelper
|
2
|
+
def sort_order
|
3
|
+
params[:order] == "desc" ? "desc" : "asc"
|
4
|
+
end
|
5
|
+
|
6
|
+
def toggled_sort_order
|
7
|
+
params[:order] == "desc" ? "asc" : "desc"
|
8
|
+
end
|
9
|
+
|
10
|
+
def current_params(merged={})
|
11
|
+
params.except(:action, :controller, :host, :port, :authenticity_token, :utf8, :commit).to_unsafe_h.merge(merged)
|
12
|
+
end
|
13
|
+
|
14
|
+
def toggled_param(toggle_h)
|
15
|
+
toggle_key = toggle_h.keys.first
|
16
|
+
toggle_val = toggle_h.values.first
|
17
|
+
|
18
|
+
if params[toggle_key].to_s == toggle_val.to_s
|
19
|
+
cmd_path(:tasks, current_params.except(toggle_key))
|
20
|
+
else
|
21
|
+
cmd_path(:tasks, current_params(toggle_h))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def truthy?(val)
|
26
|
+
val.to_s.downcase.in?(["true", "t", "1"])
|
27
|
+
end
|
28
|
+
|
29
|
+
def true_param?(*param_keys)
|
30
|
+
truthy?(params&.dig(*param_keys))
|
31
|
+
end
|
32
|
+
|
33
|
+
def humanized_duration(seconds)
|
34
|
+
return "N/A" if seconds.blank?
|
35
|
+
|
36
|
+
remaining = seconds.round
|
37
|
+
str_parts = []
|
38
|
+
|
39
|
+
durations = {
|
40
|
+
w: 7 * 24 * 60 * 60,
|
41
|
+
d: 24 * 60 * 60,
|
42
|
+
h: 60 * 60,
|
43
|
+
m: 60,
|
44
|
+
s: 1,
|
45
|
+
}
|
46
|
+
|
47
|
+
durations.each do |label, length|
|
48
|
+
count_at_length = 0
|
49
|
+
|
50
|
+
while remaining > length do
|
51
|
+
remaining -= length
|
52
|
+
count_at_length += 1
|
53
|
+
end
|
54
|
+
|
55
|
+
next if count_at_length == 0
|
56
|
+
|
57
|
+
str_parts.push("#{count_at_length}#{label}")
|
58
|
+
end
|
59
|
+
|
60
|
+
return "< 1s" if str_parts.none?
|
61
|
+
str_parts.join(" ")
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
module PermissionsHelper
|
3
|
+
def can_command?(user=command_user)
|
4
|
+
return true unless cmd_config.approval_required?
|
5
|
+
|
6
|
+
command_user.try("#{cmd_config.role_scope}?")
|
7
|
+
end
|
8
|
+
|
9
|
+
def can_approve?(iteration)
|
10
|
+
return true unless cmd_config.approval_required?
|
11
|
+
return if iteration.nil?
|
12
|
+
|
13
|
+
command_user.try("#{cmd_config.role_scope}?") && !current_is_author?(iteration)
|
14
|
+
end
|
15
|
+
|
16
|
+
def has_approval?(task)
|
17
|
+
return true unless cmd_config.approval_required?
|
18
|
+
|
19
|
+
task&.approved?
|
20
|
+
end
|
21
|
+
|
22
|
+
def current_is_author?(iteration)
|
23
|
+
command_user&.id == iteration&.requester&.id
|
24
|
+
end
|
25
|
+
|
26
|
+
def command_user(user=nil)
|
27
|
+
@command_user ||= begin
|
28
|
+
if user.present?
|
29
|
+
user
|
30
|
+
elsif cmd_config.controller_var.blank?
|
31
|
+
nil
|
32
|
+
else
|
33
|
+
try(cmd_config.controller_var)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def cmd_config
|
39
|
+
::CommandProposal.configuration
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
class CommandRunnerJob < ApplicationJob
|
3
|
+
queue_as :default
|
4
|
+
|
5
|
+
def perform(iteration_id)
|
6
|
+
iteration = ::CommandProposal::Iteration.find(iteration_id)
|
7
|
+
|
8
|
+
::CommandProposal::Services::Runner.new.execute(iteration)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# belongs_to :iteration
|
2
|
+
# integer :line_number
|
3
|
+
# belongs_to :author
|
4
|
+
# text :body
|
5
|
+
|
6
|
+
require "command_proposal/service/external_belong"
|
7
|
+
|
8
|
+
class ::CommandProposal::Comment < ApplicationRecord
|
9
|
+
self.table_name = :command_proposal_comments
|
10
|
+
include ::CommandProposal::Service::ExternalBelong
|
11
|
+
|
12
|
+
belongs_to :iteration, optional: true
|
13
|
+
external_belongs_to :author
|
14
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# has_many :comments
|
2
|
+
# belongs_to :task
|
3
|
+
# text :args
|
4
|
+
# text :code
|
5
|
+
# text :result
|
6
|
+
# integer :status
|
7
|
+
# belongs_to :requester
|
8
|
+
# belongs_to :approver
|
9
|
+
# datetime :approved_at
|
10
|
+
# datetime :started_at
|
11
|
+
# datetime :completed_at
|
12
|
+
# datetime :stopped_at
|
13
|
+
|
14
|
+
# ADD: iteration_count?
|
15
|
+
|
16
|
+
require "command_proposal/service/external_belong"
|
17
|
+
require "command_proposal/service/json_wrapper"
|
18
|
+
|
19
|
+
class ::CommandProposal::Iteration < ApplicationRecord
|
20
|
+
self.table_name = :command_proposal_iterations
|
21
|
+
serialize :args, ::CommandProposal::Service::JSONWrapper
|
22
|
+
include ::CommandProposal::Service::ExternalBelong
|
23
|
+
|
24
|
+
has_many :comments
|
25
|
+
belongs_to :task
|
26
|
+
external_belongs_to :requester
|
27
|
+
external_belongs_to :approver
|
28
|
+
|
29
|
+
enum status: {
|
30
|
+
created: 0,
|
31
|
+
approved: 1,
|
32
|
+
started: 2,
|
33
|
+
failed: 3,
|
34
|
+
cancelling: 4, # Running, but told to stop
|
35
|
+
cancelled: 5,
|
36
|
+
success: 6,
|
37
|
+
}
|
38
|
+
|
39
|
+
delegate :name, to: :task
|
40
|
+
delegate :description, to: :task
|
41
|
+
delegate :session_type, to: :task
|
42
|
+
|
43
|
+
def params
|
44
|
+
code.scan(/params\[[:\"\'](.*?)[\'\"]?\]/).flatten
|
45
|
+
end
|
46
|
+
|
47
|
+
def brings
|
48
|
+
bring_str = code.scan(/bring.*?\n/).flatten.first
|
49
|
+
return [] unless bring_str.present?
|
50
|
+
|
51
|
+
::CommandProposal::Task.module.where(friendly_id: bring_str.scan(/\s+\:(\w+),?/).flatten)
|
52
|
+
end
|
53
|
+
|
54
|
+
def complete?
|
55
|
+
success? || failed? || cancelled?
|
56
|
+
end
|
57
|
+
|
58
|
+
def pending?
|
59
|
+
created?
|
60
|
+
end
|
61
|
+
|
62
|
+
def duration
|
63
|
+
return unless started_at?
|
64
|
+
|
65
|
+
(completed_at || stopped_at || Time.current) - started_at
|
66
|
+
end
|
67
|
+
|
68
|
+
def force_reset
|
69
|
+
# Debugging method. Should never actually be called.
|
70
|
+
update(status: :approved, result: nil, completed_at: nil, stopped_at: nil, started_at: nil)
|
71
|
+
end
|
72
|
+
|
73
|
+
def line_count
|
74
|
+
return 0 if code.blank?
|
75
|
+
|
76
|
+
code.count("\n")
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
module Service
|
3
|
+
module ExternalBelong
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
def self.external_belongs_to key
|
8
|
+
define_getters key
|
9
|
+
define_setters key
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.define_getters key
|
13
|
+
define_method key do
|
14
|
+
return if user_class.blank?
|
15
|
+
|
16
|
+
if role_scope.present?
|
17
|
+
user_class.send(role_scope).find_by(id: public_send("#{key}_id"))
|
18
|
+
else
|
19
|
+
user_class.find_by(id: public_send("#{key}_id"))
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
define_method "#{key}_name" do
|
24
|
+
public_send(key)&.public_send(user_name)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.define_setters key
|
29
|
+
define_method "#{key}=" do |obj|
|
30
|
+
self.send("#{key}_id=", obj&.id)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def user_class
|
35
|
+
::CommandProposal.configuration.user_class
|
36
|
+
end
|
37
|
+
|
38
|
+
def role_scope
|
39
|
+
::CommandProposal.configuration.role_scope
|
40
|
+
end
|
41
|
+
|
42
|
+
def user_name
|
43
|
+
::CommandProposal.configuration.user_name
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
module Service
|
3
|
+
class JSONWrapper
|
4
|
+
# Allows directly setting pre-stringified JSON.
|
5
|
+
def self.dump(obj)
|
6
|
+
return obj if obj.is_a?(String)
|
7
|
+
|
8
|
+
JSON.dump(obj)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.load(str)
|
12
|
+
return {} unless str.present?
|
13
|
+
|
14
|
+
JSON.parse(str).with_indifferent_access
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module CommandProposal
|
2
|
+
module Service
|
3
|
+
class ProposalPresenter
|
4
|
+
include ::CommandProposal::ApplicationHelper
|
5
|
+
attr_accessor :iteration
|
6
|
+
|
7
|
+
def initialize(iteration)
|
8
|
+
@iteration = iteration
|
9
|
+
end
|
10
|
+
|
11
|
+
delegate :name, to: :iteration
|
12
|
+
delegate :description, to: :iteration
|
13
|
+
delegate :args, to: :iteration
|
14
|
+
delegate :code, to: :iteration
|
15
|
+
delegate :status, to: :iteration
|
16
|
+
delegate :approved_at, to: :iteration
|
17
|
+
delegate :started_at, to: :iteration
|
18
|
+
delegate :completed_at, to: :iteration
|
19
|
+
delegate :stopped_at, to: :iteration
|
20
|
+
delegate :duration, to: :iteration
|
21
|
+
|
22
|
+
def url(host: nil)
|
23
|
+
cmd_path(@iteration.task, host: host)
|
24
|
+
end
|
25
|
+
|
26
|
+
def requester
|
27
|
+
@iteration.requester_name
|
28
|
+
end
|
29
|
+
|
30
|
+
def approver
|
31
|
+
@iteration.approver_name
|
32
|
+
end
|
33
|
+
|
34
|
+
def type
|
35
|
+
@iteration.session_type
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# has_many :iterations
|
2
|
+
# text :name
|
3
|
+
# text :friendly_id
|
4
|
+
# text :description
|
5
|
+
# integer :session_type
|
6
|
+
# datetime :last_executed_at
|
7
|
+
|
8
|
+
class ::CommandProposal::Task < ApplicationRecord
|
9
|
+
self.table_name = :command_proposal_tasks
|
10
|
+
attr_accessor :user
|
11
|
+
|
12
|
+
has_many :iterations
|
13
|
+
has_many :ordered_iterations, -> { order(created_at: :desc) }, class_name: "CommandProposal::Iteration"
|
14
|
+
|
15
|
+
scope :search, ->(text) {
|
16
|
+
where("name ILIKE :q OR description ILIKE :q", q: "%#{text}%")
|
17
|
+
}
|
18
|
+
|
19
|
+
enum session_type: {
|
20
|
+
# Task will have multiple iterations that are all essentially the same just with code changes
|
21
|
+
task: 0,
|
22
|
+
# Console iterations are actually line by line, so order matters
|
23
|
+
console: 1,
|
24
|
+
# Function iterations are much like tasks
|
25
|
+
function: 2,
|
26
|
+
# Modules are included in tasks and not run independently
|
27
|
+
module: 3,
|
28
|
+
}
|
29
|
+
|
30
|
+
validates :name, presence: true
|
31
|
+
|
32
|
+
after_initialize -> { self.session_type ||= :task }
|
33
|
+
before_save -> { self.friendly_id = to_param }
|
34
|
+
|
35
|
+
delegate :line_count, to: :current_iteration, allow_nil: true
|
36
|
+
delegate :code, to: :current_iteration, allow_nil: true
|
37
|
+
delegate :result, to: :current_iteration, allow_nil: true
|
38
|
+
delegate :status, to: :primary_iteration, allow_nil: true
|
39
|
+
delegate :duration, to: :primary_iteration, allow_nil: true
|
40
|
+
|
41
|
+
def lines
|
42
|
+
iterations.order(created_at: :asc).where.not(id: first_iteration.id)
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_param
|
46
|
+
friendly_id || generate_friendly_id
|
47
|
+
end
|
48
|
+
|
49
|
+
def approved?
|
50
|
+
primary_iteration&.approved_at?
|
51
|
+
end
|
52
|
+
|
53
|
+
def first_iteration
|
54
|
+
ordered_iterations.last
|
55
|
+
end
|
56
|
+
|
57
|
+
def current_iteration
|
58
|
+
ordered_iterations.first
|
59
|
+
end
|
60
|
+
|
61
|
+
def primary_iteration
|
62
|
+
console? ? first_iteration : current_iteration
|
63
|
+
end
|
64
|
+
|
65
|
+
def current_iteration_at
|
66
|
+
current_iteration&.completed_at
|
67
|
+
end
|
68
|
+
|
69
|
+
def current_iteration_by
|
70
|
+
current_iteration&.requester_name
|
71
|
+
end
|
72
|
+
|
73
|
+
def started_at
|
74
|
+
iterations.minimum(:started_at)
|
75
|
+
end
|
76
|
+
|
77
|
+
def completed_at
|
78
|
+
iterations.maximum(:completed_at)
|
79
|
+
end
|
80
|
+
|
81
|
+
def code=(new_code)
|
82
|
+
iterations.create(code: new_code, requester: user)
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def reserved_names
|
88
|
+
[
|
89
|
+
"new",
|
90
|
+
"edit",
|
91
|
+
]
|
92
|
+
end
|
93
|
+
|
94
|
+
def generate_friendly_id
|
95
|
+
return if name.blank?
|
96
|
+
temp_id = name.downcase.gsub(/\s+/, "_").gsub(/[^a-z_]/, "")
|
97
|
+
|
98
|
+
loop do
|
99
|
+
duplicate_names = self.class.where(friendly_id: temp_id).where.not(id: id)
|
100
|
+
|
101
|
+
return temp_id if duplicate_names.none? && reserved_names.exclude?(temp_id)
|
102
|
+
|
103
|
+
temp_id = "#{temp_id}_#{duplicate_names.count}"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|