libis-workflow 2.0.beta.3
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/.coveralls.yml +2 -0
- data/.gitignore +36 -0
- data/.travis.yml +33 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +296 -0
- data/Rakefile +7 -0
- data/lib/libis/exceptions.rb +8 -0
- data/lib/libis/workflow/base/logger.rb +30 -0
- data/lib/libis/workflow/base/run.rb +68 -0
- data/lib/libis/workflow/base/workflow.rb +123 -0
- data/lib/libis/workflow/config.rb +92 -0
- data/lib/libis/workflow/message_registry.rb +32 -0
- data/lib/libis/workflow/run.rb +27 -0
- data/lib/libis/workflow/task.rb +259 -0
- data/lib/libis/workflow/tasks/analyzer.rb +41 -0
- data/lib/libis/workflow/version.rb +7 -0
- data/lib/libis/workflow/worker.rb +42 -0
- data/lib/libis/workflow/workflow.rb +29 -0
- data/lib/libis/workflow/workitems/dir_item.rb +12 -0
- data/lib/libis/workflow/workitems/file_item.rb +78 -0
- data/lib/libis/workflow/workitems/work_item.rb +231 -0
- data/lib/libis/workflow/workitems.rb +5 -0
- data/lib/libis/workflow.rb +28 -0
- data/lib/libis-workflow.rb +2 -0
- data/libis-workflow.gemspec +36 -0
- data/spec/items/test_dir_item.rb +16 -0
- data/spec/items/test_file_item.rb +19 -0
- data/spec/items/test_run.rb +10 -0
- data/spec/items.rb +3 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/task_spec.rb +17 -0
- data/spec/tasks/camelize_name.rb +13 -0
- data/spec/tasks/checksum_tester.rb +33 -0
- data/spec/tasks/collect_files.rb +48 -0
- data/spec/workflow_spec.rb +231 -0
- metadata +187 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
require 'logger'
|
5
|
+
|
6
|
+
module Libis
|
7
|
+
module Workflow
|
8
|
+
|
9
|
+
class Config
|
10
|
+
include Singleton
|
11
|
+
|
12
|
+
attr_accessor :logger, :workdir, :taskdir, :itemdir
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
Config.require_all(File.join(File.dirname(__FILE__), 'tasks'))
|
18
|
+
@logger = ::Logger.new STDOUT
|
19
|
+
set_formatter
|
20
|
+
@workdir = './work'
|
21
|
+
self.taskdir = './tasks'
|
22
|
+
self.itemdir = './items'
|
23
|
+
end
|
24
|
+
|
25
|
+
public
|
26
|
+
|
27
|
+
def set_formatter(formatter = nil)
|
28
|
+
@logger.formatter = formatter || proc do |severity, time, progname, msg|
|
29
|
+
"%s, [%s#%d] %5s -- %s: %s\n" % [severity[0..0],
|
30
|
+
(time.strftime('%Y-%m-%dT%H:%M:%S.') << '%06d ' % time.usec),
|
31
|
+
$$, severity, progname, msg]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.logger
|
36
|
+
instance.logger
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.workdir
|
40
|
+
instance.workdir
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.taskdir
|
44
|
+
instance.taskdir
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.itemdir
|
48
|
+
instance.itemdir
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.logger=(log)
|
52
|
+
instance.logger = log
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.set_formatter(formatter = nil)
|
56
|
+
instance.set_formatter(formatter)
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.workdir=(dir)
|
60
|
+
instance.workdir = dir
|
61
|
+
end
|
62
|
+
|
63
|
+
def taskdir=(dir)
|
64
|
+
@taskdir = dir
|
65
|
+
Config.require_all dir
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.taskdir=(dir)
|
69
|
+
instance.taskdir = dir
|
70
|
+
end
|
71
|
+
|
72
|
+
def itemdir=(dir)
|
73
|
+
@itemdir = dir
|
74
|
+
Config.require_all dir
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.itemdir=(dir)
|
78
|
+
instance.itemdir = dir
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.require_all(dir)
|
82
|
+
return unless Dir.exist?(dir)
|
83
|
+
Dir.glob(File.join(dir, '*.rb')).each do |filename|
|
84
|
+
#noinspection RubyResolve
|
85
|
+
require filename
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Libis
|
6
|
+
module Workflow
|
7
|
+
class MessageRegistry
|
8
|
+
include Singleton
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@message_db = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def register_message(id, message)
|
15
|
+
@message_db[id] = message
|
16
|
+
end
|
17
|
+
|
18
|
+
def get_message(id)
|
19
|
+
@message_db[id]
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.register_message(id, message)
|
23
|
+
self.instance.register_message id, message
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.get_message(id)
|
27
|
+
self.instance.get_message id
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'libis/workflow/config'
|
4
|
+
require 'libis/workflow/workflow'
|
5
|
+
|
6
|
+
require 'libis/workflow/base/run'
|
7
|
+
|
8
|
+
module Libis
|
9
|
+
module Workflow
|
10
|
+
|
11
|
+
class Run
|
12
|
+
include ::Libis::Workflow::Base::Run
|
13
|
+
|
14
|
+
attr_accessor :start_date, :tasks, :workflow
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
@start_date = Time.now
|
18
|
+
@tasks = nil
|
19
|
+
@workflow = nil
|
20
|
+
# noinspection RubySuperCallWithoutSuperclassInspection
|
21
|
+
super
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,259 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'backports/rails/hash'
|
3
|
+
require 'backports/rails/string'
|
4
|
+
|
5
|
+
require 'libis/tools/parameter'
|
6
|
+
|
7
|
+
require 'libis/workflow'
|
8
|
+
require 'libis/workflow/base/logger'
|
9
|
+
|
10
|
+
module Libis
|
11
|
+
module Workflow
|
12
|
+
|
13
|
+
# noinspection RubyTooManyMethodsInspection
|
14
|
+
class Task
|
15
|
+
include Base::Logger
|
16
|
+
include ::Libis::Tools::ParameterContainer
|
17
|
+
|
18
|
+
attr_accessor :parent, :name, :options, :workitem, :tasks
|
19
|
+
|
20
|
+
parameter abort_on_error: false, description: 'Stop all tasks when an error occurs.'
|
21
|
+
parameter allways_run: false, description: 'Run this task, even if the item failed a previous task.'
|
22
|
+
parameter subitems: false, description: 'Do not process the given item, but only the subitems.'
|
23
|
+
parameter recursive: false, description: 'Run the task on all subitems recursively.'
|
24
|
+
|
25
|
+
def self.task_classes
|
26
|
+
ObjectSpace.each_object(::Class).select {|klass| klass < self}
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(parent, cfg = {})
|
30
|
+
self.parent = parent
|
31
|
+
self.tasks = []
|
32
|
+
configure cfg
|
33
|
+
end
|
34
|
+
|
35
|
+
def <<(task)
|
36
|
+
self.tasks << task
|
37
|
+
end
|
38
|
+
|
39
|
+
def run(item)
|
40
|
+
|
41
|
+
check_item_type WorkItem, item
|
42
|
+
|
43
|
+
return if item.failed? unless options[:allways_run]
|
44
|
+
|
45
|
+
if options[:subitems]
|
46
|
+
log_started item
|
47
|
+
run_subitems item
|
48
|
+
log_done(item) unless item.failed?
|
49
|
+
else
|
50
|
+
run_item(item)
|
51
|
+
end
|
52
|
+
|
53
|
+
item.save
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
def run_item(item)
|
58
|
+
|
59
|
+
begin
|
60
|
+
|
61
|
+
self.workitem = item
|
62
|
+
|
63
|
+
log_started item
|
64
|
+
|
65
|
+
pre_process item
|
66
|
+
process_item item
|
67
|
+
post_process item
|
68
|
+
|
69
|
+
rescue WorkflowError => e
|
70
|
+
error e.message
|
71
|
+
log_failed item
|
72
|
+
|
73
|
+
rescue WorkflowAbort => e
|
74
|
+
item.status = to_status :failed
|
75
|
+
raise e if parent
|
76
|
+
|
77
|
+
rescue ::Exception => e
|
78
|
+
fatal 'Exception occured: %s', e.message
|
79
|
+
debug e.backtrace.join("\n")
|
80
|
+
log_failed item
|
81
|
+
end
|
82
|
+
|
83
|
+
log_done item unless item.failed?
|
84
|
+
|
85
|
+
end
|
86
|
+
|
87
|
+
def names
|
88
|
+
(self.parent.names rescue Array.new).push(name).compact
|
89
|
+
end
|
90
|
+
|
91
|
+
def namepath; self.names.join('/'); end
|
92
|
+
|
93
|
+
def apply_options(opts)
|
94
|
+
o = opts[self.name] || opts[self.names.join('/')]
|
95
|
+
|
96
|
+
default_values.each do |name,_|
|
97
|
+
next unless o.key?(name)
|
98
|
+
parameter = get_parameter_definition name
|
99
|
+
self.options[name] = parameter.parse(o[name])
|
100
|
+
end if o and o.is_a? Hash
|
101
|
+
|
102
|
+
self.tasks.each do |task|
|
103
|
+
task.apply_options opts
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
protected
|
108
|
+
|
109
|
+
def log_started(item)
|
110
|
+
item.status = to_status :started
|
111
|
+
debug 'Started', item
|
112
|
+
end
|
113
|
+
|
114
|
+
def log_failed(item, message = nil)
|
115
|
+
warn (message || 'Failed'), item
|
116
|
+
item.status = to_status :failed
|
117
|
+
end
|
118
|
+
|
119
|
+
def log_done(item)
|
120
|
+
debug 'Completed', item
|
121
|
+
item.status = to_status :done
|
122
|
+
end
|
123
|
+
|
124
|
+
def process_item(item)
|
125
|
+
process item
|
126
|
+
run_subitems(item) if options[:recursive]
|
127
|
+
run_subtasks item
|
128
|
+
end
|
129
|
+
|
130
|
+
def process(item)
|
131
|
+
# needs implementation unless there are subtasks
|
132
|
+
raise RuntimeError, 'Should be overwritten' if self.tasks.empty?
|
133
|
+
end
|
134
|
+
|
135
|
+
def pre_process(_)
|
136
|
+
# optional implementation
|
137
|
+
end
|
138
|
+
|
139
|
+
def post_process(_)
|
140
|
+
# optional implementation
|
141
|
+
end
|
142
|
+
|
143
|
+
def get_root_item
|
144
|
+
self.workitem.root
|
145
|
+
end
|
146
|
+
|
147
|
+
def get_work_dir
|
148
|
+
get_root_item.work_dir
|
149
|
+
end
|
150
|
+
|
151
|
+
def capture_cmd(cmd, *opts)
|
152
|
+
out = StringIO.new
|
153
|
+
err = StringIO.new
|
154
|
+
$stdout = out
|
155
|
+
$stderr = err
|
156
|
+
status = system cmd, *opts
|
157
|
+
return [status, out.string, err.string]
|
158
|
+
ensure
|
159
|
+
$stdout = STDOUT
|
160
|
+
$stderr = STDERR
|
161
|
+
end
|
162
|
+
|
163
|
+
def run_subitems(parent_item)
|
164
|
+
items = subitems parent_item
|
165
|
+
failed = passed = 0
|
166
|
+
items.each_with_index do |item, i|
|
167
|
+
debug 'Processing subitem (%d/%d): %s', parent_item, i+1, items.count, item.to_s
|
168
|
+
run_item item
|
169
|
+
if item.failed?
|
170
|
+
failed += 1
|
171
|
+
if options[:abort_on_error]
|
172
|
+
error 'Aborting ...', parent_item
|
173
|
+
raise WorkflowAbort.new "Aborting: task #{name} failed on #{item}"
|
174
|
+
end
|
175
|
+
else
|
176
|
+
passed += 1
|
177
|
+
end
|
178
|
+
end
|
179
|
+
if failed > 0
|
180
|
+
warn '%d subitem(s) failed', parent_item, failed
|
181
|
+
if failed == items.count
|
182
|
+
error 'All subitems have failed', parent_item
|
183
|
+
log_failed parent_item
|
184
|
+
return
|
185
|
+
end
|
186
|
+
end
|
187
|
+
debug '%d of %d subitems passed', parent_item, passed, items.count if items.count > 0
|
188
|
+
end
|
189
|
+
|
190
|
+
def run_subtasks(item)
|
191
|
+
tasks = subtasks item
|
192
|
+
tasks.each_with_index do |task, i|
|
193
|
+
debug 'Running subtask (%d/%d): %s', item, i+1, tasks.count, task.name
|
194
|
+
task.run item
|
195
|
+
if item.failed?
|
196
|
+
if task.options[:abort_on_error]
|
197
|
+
error 'Aborting ...'
|
198
|
+
raise WorkflowAbort.new "Aborting: task #{task.name} failed on #{item}"
|
199
|
+
end
|
200
|
+
return
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def configure(cfg)
|
206
|
+
self.name = cfg[:name] || (cfg[:class] || self.class).to_s.split('::').last
|
207
|
+
self.options =
|
208
|
+
default_values.merge(
|
209
|
+
cfg[:options] || {}
|
210
|
+
).merge(
|
211
|
+
cfg.reject { |k, _| [:options].include? k.to_sym }
|
212
|
+
).symbolize_keys!
|
213
|
+
end
|
214
|
+
|
215
|
+
def to_status(text)
|
216
|
+
[text.to_s.capitalize, self.names]
|
217
|
+
end
|
218
|
+
|
219
|
+
def check_item_type(klass, item = nil)
|
220
|
+
item ||= self.workitem
|
221
|
+
unless item.is_a? klass.to_s.constantize
|
222
|
+
raise WorkflowError, "Workitem is of wrong type : #{item.class} - expected #{klass.to_s}"
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def item_type?(klass, item = nil)
|
227
|
+
item ||= self.workitem
|
228
|
+
item.is_a? klass.to_s.constantize
|
229
|
+
end
|
230
|
+
|
231
|
+
private
|
232
|
+
|
233
|
+
def subtasks(item = nil)
|
234
|
+
self.tasks.map do |task|
|
235
|
+
((item || self.workitem).failed? and not task.options[:always_run]) ? nil : task
|
236
|
+
end.compact
|
237
|
+
end
|
238
|
+
|
239
|
+
def subitems(item = nil)
|
240
|
+
items = (item || workitem).items
|
241
|
+
return items if self.options[:always_run]
|
242
|
+
items.reject { |i| i.failed? }
|
243
|
+
end
|
244
|
+
|
245
|
+
def default_values
|
246
|
+
self.class.default_values
|
247
|
+
end
|
248
|
+
|
249
|
+
def self.default_values
|
250
|
+
parameters.inject({}) do |hash,parameter|
|
251
|
+
hash[parameter.first] = parameter.last[:default]
|
252
|
+
hash
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
end
|
257
|
+
|
258
|
+
end
|
259
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'libis/workflow/task'
|
4
|
+
|
5
|
+
module Libis
|
6
|
+
module Workflow
|
7
|
+
module Tasks
|
8
|
+
|
9
|
+
class Analyzer < Task
|
10
|
+
|
11
|
+
def default_options
|
12
|
+
{ quiet: true, allways_run: true }
|
13
|
+
end
|
14
|
+
|
15
|
+
def run(item)
|
16
|
+
|
17
|
+
item.properties[:ingest_failed] = item.failed?
|
18
|
+
|
19
|
+
item.log_history.each do |log|
|
20
|
+
level = log[:severity]
|
21
|
+
item.summary[level] ||= 0
|
22
|
+
item.summary[level] += 1
|
23
|
+
end
|
24
|
+
|
25
|
+
item.each do |i|
|
26
|
+
run i
|
27
|
+
i.summary.each do |level, count|
|
28
|
+
item.summary[level] ||= 0
|
29
|
+
item.summary[level] += (count || 0)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
item.save
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'sidekiq'
|
3
|
+
|
4
|
+
require 'libis/workflow/config'
|
5
|
+
require 'libis/workflow/workflow'
|
6
|
+
|
7
|
+
module Libis
|
8
|
+
module Workflow
|
9
|
+
|
10
|
+
class Worker
|
11
|
+
include Sidekiq::Worker
|
12
|
+
|
13
|
+
def perform(workflow_config, options = {})
|
14
|
+
workflow = self.class.configure(workflow_config, options)
|
15
|
+
options[:interactive] = false
|
16
|
+
workflow.run options
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.configure(workflow_config, options = {})
|
20
|
+
log_path = options.delete :log_path
|
21
|
+
if log_path
|
22
|
+
Config.logger = ::Logger.new(
|
23
|
+
File.join(log_path, "#{workflow_config}.log"),
|
24
|
+
(options.delete(:log_shift_age) || 'daily'),
|
25
|
+
(options.delete(:log_shift_size) || 1024 ** 2)
|
26
|
+
)
|
27
|
+
Config.logger.formatter = ::Logger::Formatter.new
|
28
|
+
Config.logger.level = ::Logger::DEBUG
|
29
|
+
end
|
30
|
+
get_workflow(workflow_config)
|
31
|
+
end
|
32
|
+
|
33
|
+
def get_workflow(workflow_config)
|
34
|
+
workflow = ::Libis::Workflow::Workflow.new
|
35
|
+
workflow.configure workflow_config
|
36
|
+
workflow
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'backports/rails/string'
|
4
|
+
require 'backports/rails/hash'
|
5
|
+
|
6
|
+
require 'libis/workflow/config'
|
7
|
+
require 'libis/workflow/task'
|
8
|
+
require 'libis/workflow/tasks/analyzer'
|
9
|
+
|
10
|
+
require 'libis/workflow/base/workflow'
|
11
|
+
|
12
|
+
module Libis
|
13
|
+
module Workflow
|
14
|
+
|
15
|
+
class Workflow
|
16
|
+
include ::Libis::Workflow::Base::Workflow
|
17
|
+
|
18
|
+
attr_accessor :name, :description, :config
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@name = ''
|
22
|
+
@descripition = ''
|
23
|
+
@config = Hash.new
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'digest'
|
4
|
+
|
5
|
+
require 'libis/workflow/workitems/work_item'
|
6
|
+
|
7
|
+
module Libis
|
8
|
+
module Workflow
|
9
|
+
|
10
|
+
module FileItem
|
11
|
+
include WorkItem
|
12
|
+
|
13
|
+
def filename
|
14
|
+
File.basename(self.properties[:filename]) || self.properties[:link]
|
15
|
+
end
|
16
|
+
|
17
|
+
def name
|
18
|
+
self.properties[:name] || self.filename
|
19
|
+
end
|
20
|
+
|
21
|
+
def filelist
|
22
|
+
(self.parent.filelist rescue Array.new).push(filename).compact
|
23
|
+
end
|
24
|
+
|
25
|
+
def filepath
|
26
|
+
self.filelist.join('/')
|
27
|
+
end
|
28
|
+
|
29
|
+
def fullpath
|
30
|
+
self.properties[:filename]
|
31
|
+
end
|
32
|
+
|
33
|
+
def filename=(name)
|
34
|
+
begin
|
35
|
+
stats = ::File.stat name
|
36
|
+
self.properties[:size] = stats.size
|
37
|
+
self.properties[:access_time] = stats.atime
|
38
|
+
self.properties[:modification_time] = stats.mtime
|
39
|
+
self.properties[:creation_time] = stats.ctime
|
40
|
+
self.properties[:mode] = stats.mode
|
41
|
+
self.properties[:uid] = stats.uid
|
42
|
+
self.properties[:gid] = stats.gid
|
43
|
+
set_checksum(:MD5, ::Digest::MD5.hexdigest(File.read(name))) if File.file?(name)
|
44
|
+
rescue
|
45
|
+
# ignored
|
46
|
+
end
|
47
|
+
self.properties[:filename] = name
|
48
|
+
end
|
49
|
+
|
50
|
+
def checksum(checksum_type)
|
51
|
+
self.properties[('checksum_' + checksum_type.to_s.downcase).to_sym]
|
52
|
+
end
|
53
|
+
|
54
|
+
def set_checksum(checksum_type, value)
|
55
|
+
self.properties[('checksum_' + checksum_type.to_s.downcase).to_sym] = value
|
56
|
+
end
|
57
|
+
|
58
|
+
def link
|
59
|
+
self.properties[:link]
|
60
|
+
end
|
61
|
+
|
62
|
+
def link=(name)
|
63
|
+
self.properties[:link] = name
|
64
|
+
end
|
65
|
+
|
66
|
+
def set_info(info)
|
67
|
+
info.each do |k, v|
|
68
|
+
self.properties[k] = v
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def safe_name
|
73
|
+
self.name.to_s.gsub(/[^\w.-]/) { |s| '%%%02x' % s.ord }
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|