markdown_exec 0.2.0 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
data/lib/markdown_exec.rb CHANGED
@@ -3,20 +3,42 @@
3
3
 
4
4
  # encoding=utf-8
5
5
 
6
- $pdebug = !(ENV['MARKDOWN_EXEC_DEBUG'] || '').empty?
7
-
8
6
  require 'open3'
9
7
  require 'optparse'
10
8
  require 'tty-prompt'
11
9
  require 'yaml'
12
10
 
11
+ ##
12
+ # default if nil
13
+ # false if empty or '0'
14
+ # else true
15
+
16
+ def env_bool(name, default: false)
17
+ return default if (val = ENV[name]).nil?
18
+ return false if val.empty? || val == '0'
19
+
20
+ true
21
+ end
22
+
23
+ def env_int(name, default: 0)
24
+ return default if (val = ENV[name]).nil?
25
+ return default if val.empty?
26
+
27
+ val.to_i
28
+ end
29
+
30
+ def env_str(name, default: '')
31
+ ENV[name] || default
32
+ end
33
+
34
+ $pdebug = env_bool 'MDE_DEBUG'
35
+
13
36
  require_relative 'markdown_exec/version'
14
37
 
15
38
  $stderr.sync = true
16
39
  $stdout.sync = true
17
40
 
18
41
  BLOCK_SIZE = 1024
19
- SELECT_PAGE_HEIGHT = 12
20
42
 
21
43
  class Object # rubocop:disable Style/Documentation
22
44
  def present?
@@ -31,6 +53,30 @@ class String # rubocop:disable Style/Documentation
31
53
  end
32
54
  end
33
55
 
56
+ public
57
+
58
+ # debug output
59
+ #
60
+ def tap_inspect(format: nil, name: 'return')
61
+ return self unless $pdebug
62
+
63
+ fn = case format
64
+ when :json
65
+ :to_json
66
+ when :string
67
+ :to_s
68
+ when :yaml
69
+ :to_yaml
70
+ else
71
+ :inspect
72
+ end
73
+
74
+ puts "-> #{caller[0].scan(/in `?(\S+)'$/)[0][0]}()" \
75
+ " #{name}: #{method(fn).call}"
76
+
77
+ self
78
+ end
79
+
34
80
  module MarkdownExec
35
81
  class Error < StandardError; end
36
82
 
@@ -41,6 +87,65 @@ module MarkdownExec
41
87
 
42
88
  def initialize(options = {})
43
89
  @options = options
90
+ @prompt = TTY::Prompt.new(interrupt: :exit)
91
+ end
92
+
93
+ ##
94
+ # options necessary to start, parse input, defaults for cli options
95
+
96
+ def base_options
97
+ {
98
+ # commands
99
+ list_blocks: false, # command
100
+ list_docs: false, # command
101
+ list_recent_scripts: false, # command
102
+ run_last_script: false, # command
103
+ select_recent_script: false, # command
104
+
105
+ # command options
106
+ filename: env_str('MDE_FILENAME', default: nil), # option Filename to open
107
+ list_count: 16,
108
+ logged_stdout_filename_prefix: 'mde',
109
+ output_execution_summary: env_bool('MDE_OUTPUT_EXECUTION_SUMMARY', default: false), # option
110
+ output_script: env_bool('MDE_OUTPUT_SCRIPT', default: false), # option
111
+ output_stdout: env_bool('MDE_OUTPUT_STDOUT', default: true), # option
112
+ path: env_str('MDE_PATH', default: nil), # option Folder to search for files
113
+ save_executed_script: env_bool('MDE_SAVE_EXECUTED_SCRIPT', default: false), # option
114
+ save_execution_output: env_bool('MDE_SAVE_EXECUTION_OUTPUT', default: false), # option
115
+ saved_script_filename_prefix: 'mde',
116
+ saved_script_folder: env_str('MDE_SAVED_SCRIPT_FOLDER', default: 'logs'), # option
117
+ saved_script_glob: 'mde_*.sh',
118
+ saved_stdout_folder: env_str('MDE_SAVED_STDOUT_FOLDER', default: 'logs'), # option
119
+ user_must_approve: env_bool('MDE_USER_MUST_APPROVE', default: true), # option Pause for user to approve script
120
+
121
+ # configuration options
122
+ block_name_excluded_match: env_str('MDE_BLOCK_NAME_EXCLUDED_MATCH', default: '^\(.+\)$'),
123
+ block_name_match: env_str('MDE_BLOCK_NAME_MATCH', default: ':(?<title>\S+)( |$)'),
124
+ block_required_scan: env_str('MDE_BLOCK_REQUIRED_SCAN', default: '\+\S+'),
125
+ fenced_start_and_end_match: env_str('MDE_FENCED_START_AND_END_MATCH', default: '^`{3,}'),
126
+ fenced_start_ex_match: env_str('MDE_FENCED_START_EX_MATCH', default: '^`{3,}(?<shell>[^`\s]*) *(?<name>.*)$'),
127
+ heading1_match: env_str('MDE_HEADING1_MATCH', default: '^# *(?<name>[^#]*?) *$'),
128
+ heading2_match: env_str('MDE_HEADING2_MATCH', default: '^## *(?<name>[^#]*?) *$'),
129
+ heading3_match: env_str('MDE_HEADING3_MATCH', default: '^### *(?<name>.+?) *$'),
130
+ md_filename_glob: env_str('MDE_MD_FILENAME_GLOB', default: '*.[Mm][Dd]'),
131
+ md_filename_match: env_str('MDE_MD_FILENAME_MATCH', default: '.+\\.md'),
132
+ mdheadings: true, # use headings (levels 1,2,3) in block lable
133
+ select_page_height: env_int('MDE_SELECT_PAGE_HEIGHT', default: 12)
134
+ }
135
+ end
136
+
137
+ def default_options
138
+ {
139
+ bash: true, # bash block parsing in get_block_summary()
140
+ exclude_expect_blocks: true,
141
+ hide_blocks_by_name: true,
142
+ output_saved_script_filename: false,
143
+ prompt_approve_block: 'Process?',
144
+ prompt_select_block: 'Choose a block:',
145
+ prompt_select_md: 'Choose a file:',
146
+ saved_script_filename: nil, # calculated
147
+ struct: true # allow get_block_summary()
148
+ }
44
149
  end
45
150
 
46
151
  # Returns true if all files are EOF
@@ -49,16 +154,145 @@ module MarkdownExec
49
154
  files.find { |f| !f.eof }.nil?
50
155
  end
51
156
 
157
+ def approve_block(opts, blocks_in_file)
158
+ required_blocks = list_recursively_required_blocks(blocks_in_file, opts[:block_name])
159
+ display_command(opts, required_blocks) if opts[:output_script] || opts[:user_must_approve]
160
+
161
+ allow = true
162
+ allow = @prompt.yes? opts[:prompt_approve_block] if opts[:user_must_approve]
163
+ opts[:ir_approve] = allow
164
+ selected = get_block_by_name blocks_in_file, opts[:block_name]
165
+
166
+ if opts[:ir_approve]
167
+ write_command_file(opts, required_blocks) if opts[:save_executed_script]
168
+ command_execute opts, required_blocks.flatten.join("\n")
169
+ end
170
+
171
+ selected[:name]
172
+ end
173
+
174
+ def code(table, block)
175
+ all = [block[:name]] + recursively_required(table, block[:reqs])
176
+ all.reverse.map do |req|
177
+ get_block_by_name(table, req).fetch(:body, '')
178
+ end
179
+ .flatten(1)
180
+ .tap_inspect
181
+ end
182
+
183
+ def command_execute(opts, cmd2)
184
+ @execute_files = Hash.new([])
185
+ @execute_options = opts
186
+ @execute_started_at = Time.now.utc
187
+ Open3.popen3(cmd2) do |stdin, stdout, stderr|
188
+ stdin.close_write
189
+ begin
190
+ files = [stdout, stderr]
191
+
192
+ until all_at_eof(files)
193
+ ready = IO.select(files)
194
+
195
+ next unless ready
196
+
197
+ # readable = ready[0]
198
+ # # writable = ready[1]
199
+ # # exceptions = ready[2]
200
+ ready.each.with_index do |readable, ind|
201
+ readable.each do |f|
202
+ block = f.read_nonblock(BLOCK_SIZE)
203
+ @execute_files[ind] = @execute_files[ind] + [block]
204
+ print block if opts[:output_stdout]
205
+ rescue EOFError #=> e
206
+ # do nothing at EOF
207
+ end
208
+ end
209
+ end
210
+ rescue IOError => e
211
+ fout "IOError: #{e}"
212
+ end
213
+ @execute_completed_at = Time.now.utc
214
+ end
215
+ rescue Errno::ENOENT => e
216
+ # error triggered by missing command in script
217
+ @execute_aborted_at = Time.now.utc
218
+ @execute_error_message = e.message
219
+ @execute_error = e
220
+ @execute_files[1] = e.message
221
+ fout "Error ENOENT: #{e.inspect}"
222
+ end
223
+
52
224
  def count_blocks_in_filename
225
+ fenced_start_and_end_match = Regexp.new @options[:fenced_start_and_end_match]
53
226
  cnt = 0
54
- File.readlines(options[:filename]).each do |line|
55
- cnt += 1 if line.match(/^```/)
227
+ File.readlines(@options[:filename]).each do |line|
228
+ cnt += 1 if line.match(fenced_start_and_end_match)
56
229
  end
57
230
  cnt / 2
58
231
  end
59
232
 
233
+ def display_command(_opts, required_blocks)
234
+ required_blocks.each { |cb| fout cb }
235
+ end
236
+
237
+ def exec_block(options, _block_name = '')
238
+ options = default_options.merge options
239
+ update_options options, over: false
240
+
241
+ # document and block reports
242
+ #
243
+ files = list_files_per_options(options)
244
+ if @options[:list_blocks]
245
+ fout_list (files.map do |file|
246
+ make_block_labels(filename: file, struct: true)
247
+ end).flatten(1)
248
+ return
249
+ end
250
+
251
+ if @options[:list_docs]
252
+ fout_list files
253
+ return
254
+ end
255
+
256
+ if @options[:list_recent_scripts]
257
+ fout_list list_recent_scripts
258
+ return
259
+ end
260
+
261
+ if @options[:run_last_script]
262
+ run_last_script
263
+ return
264
+ end
265
+
266
+ if @options[:select_recent_script]
267
+ select_recent_script
268
+ return
269
+ end
270
+
271
+ # process
272
+ #
273
+ @options[:filename] = select_md_file(files)
274
+ select_and_approve_block(
275
+ bash: true,
276
+ struct: true
277
+ )
278
+ fout "saved_filespec: #{@execute_script_filespec}" if @options[:output_saved_script_filename]
279
+ save_execution_output
280
+ output_execution_summary
281
+ end
282
+
283
+ # standard output; not for debug
284
+ #
60
285
  def fout(str)
61
- puts str # to stdout
286
+ puts str
287
+ end
288
+
289
+ def fout_list(str)
290
+ puts str
291
+ end
292
+
293
+ def fout_section(name, data)
294
+ puts "# #{name}"
295
+ puts data.to_yaml
62
296
  end
63
297
 
64
298
  def get_block_by_name(table, name, default = {})
@@ -70,11 +304,11 @@ module MarkdownExec
70
304
 
71
305
  return [summarize_block(headings, block_title).merge({ body: current })] unless opts[:bash]
72
306
 
73
- bm = block_title.match(/:(\S+)( |$)/)
74
- reqs = block_title.scan(/\+\S+/).map { |s| s[1..] }
307
+ bm = block_title.match(Regexp.new(opts[:block_name_match]))
308
+ reqs = block_title.scan(Regexp.new(opts[:block_required_scan])).map { |s| s[1..] }
75
309
 
76
310
  if bm && bm[1]
77
- [summarize_block(headings, bm[1]).merge({ body: current, reqs: reqs })]
311
+ [summarize_block(headings, bm[:title]).merge({ body: current, reqs: reqs })]
78
312
  else
79
313
  [summarize_block(headings, block_title).merge({ body: current, reqs: reqs })]
80
314
  end
@@ -88,120 +322,125 @@ module MarkdownExec
88
322
  exit 1
89
323
  end
90
324
 
325
+ fenced_start_and_end_match = Regexp.new opts[:fenced_start_and_end_match]
326
+ fenced_start_ex = Regexp.new opts[:fenced_start_ex_match]
327
+ block_title = ''
91
328
  blocks = []
92
329
  current = nil
93
- in_block = false
94
- block_title = ''
95
-
96
330
  headings = []
331
+ in_block = false
97
332
  File.readlines(opts[:filename]).each do |line|
98
333
  continue unless line
99
334
 
100
335
  if opts[:mdheadings]
101
- if (lm = line.match(/^### *(.+?) *$/))
102
- headings = [headings[0], headings[1], lm[1]]
103
- elsif (lm = line.match(/^## *([^#]*?) *$/))
104
- headings = [headings[0], lm[1]]
105
- elsif (lm = line.match(/^# *([^#]*?) *$/))
106
- headings = [lm[1]]
336
+ if (lm = line.match(Regexp.new(opts[:heading3_match])))
337
+ headings = [headings[0], headings[1], lm[:name]]
338
+ elsif (lm = line.match(Regexp.new(opts[:heading2_match])))
339
+ headings = [headings[0], lm[:name]]
340
+ elsif (lm = line.match(Regexp.new(opts[:heading1_match])))
341
+ headings = [lm[:name]]
107
342
  end
108
343
  end
109
344
 
110
- if line.match(/^`{3,}/)
345
+ if line.match(fenced_start_and_end_match)
111
346
  if in_block
112
347
  if current
113
-
114
348
  block_title = current.join(' ').gsub(/ +/, ' ')[0..64] if block_title.nil? || block_title.empty?
115
-
116
349
  blocks += get_block_summary opts, headings, block_title, current
117
350
  current = nil
118
351
  end
119
352
  in_block = false
120
353
  block_title = ''
121
354
  else
122
- ## new block
355
+ # new block
123
356
  #
124
-
125
- lm = line.match(/^`{3,}([^`\s]*) *(.*)$/)
126
-
357
+ lm = line.match(fenced_start_ex)
127
358
  do1 = false
128
359
  if opts[:bash_only]
129
- do1 = true if lm && (lm[1] == 'bash')
130
- elsif opts[:exclude_expect_blocks]
131
- do1 = true unless lm && (lm[1] == 'expect')
360
+ do1 = true if lm && (lm[:shell] == 'bash')
132
361
  else
133
362
  do1 = true
363
+ do1 = !(lm && (lm[:shell] == 'expect')) if opts[:exclude_expect_blocks]
134
364
  end
135
365
 
136
- if do1 && (!opts[:title_match] || (lm && lm[2] && lm[2].match(opts[:title_match])))
366
+ in_block = true
367
+ if do1 && (!opts[:title_match] || (lm && lm[:name] && lm[:name].match(opts[:title_match])))
137
368
  current = []
138
- in_block = true
139
- block_title = (lm && lm[2])
369
+ block_title = (lm && lm[:name])
140
370
  end
141
-
142
371
  end
143
372
  elsif current
144
373
  current += [line.chomp]
145
374
  end
146
375
  end
147
- blocks.tap { |ret| puts "list_blocks_in_file() ret: #{ret.inspect}" if $pdebug }
376
+ blocks.tap_inspect
148
377
  end
149
378
 
150
379
  def list_files_per_options(options)
151
380
  default_filename = 'README.md'
152
381
  default_folder = '.'
153
382
  if options[:filename]&.present?
154
- list_files_specified(options[:filename], options[:folder], default_filename, default_folder)
383
+ list_files_specified(options[:filename], options[:path], default_filename, default_folder)
155
384
  else
156
- list_files_specified(nil, options[:folder], default_filename, default_folder)
157
- end
385
+ list_files_specified(nil, options[:path], default_filename, default_folder)
386
+ end.tap_inspect
158
387
  end
159
388
 
160
389
  def list_files_specified(specified_filename, specified_folder, default_filename, default_folder, filetree = nil)
161
- fn = if specified_filename&.present?
162
- if specified_folder&.present?
163
- "#{specified_folder}/#{specified_filename}"
164
- else
165
- "#{default_folder}/#{specified_filename}"
166
- end
167
- elsif specified_folder&.present?
168
- if filetree
169
- "#{specified_folder}/.+\\.md"
170
- else
171
- "#{specified_folder}/*.[Mm][Dd]"
172
- end
173
- else
174
- "#{default_folder}/#{default_filename}"
175
- end
390
+ fn = File.join(if specified_filename&.present?
391
+ if specified_folder&.present?
392
+ [specified_folder, specified_filename]
393
+ elsif specified_filename.start_with? '/'
394
+ [specified_filename]
395
+ else
396
+ [default_folder, specified_filename]
397
+ end
398
+ elsif specified_folder&.present?
399
+ if filetree
400
+ [specified_folder, @options[:md_filename_match]]
401
+ else
402
+ [specified_folder, @options[:md_filename_glob]]
403
+ end
404
+ else
405
+ [default_folder, default_filename]
406
+ end)
176
407
  if filetree
177
408
  filetree.select { |filename| filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$}) }
178
409
  else
179
410
  Dir.glob(fn)
180
- end.tap { |ret| puts "list_files_specified() ret: #{ret.inspect}" if $pdebug }
411
+ end.tap_inspect
181
412
  end
182
413
 
183
- def list_markdown_files_in_folder
184
- Dir.glob(File.join(options[:folder], '*.md'))
414
+ def list_markdown_files_in_path
415
+ Dir.glob(File.join(@options[:path], @options[:md_filename_glob])).tap_inspect
185
416
  end
186
417
 
187
- def code(table, block)
188
- all = [block[:name]] + recursively_required(table, block[:reqs])
189
- all.reverse.map do |req|
190
- get_block_by_name(table, req).fetch(:body, '')
191
- end
192
- .flatten(1)
193
- .tap { |ret| puts "code() ret: #{ret.inspect}" if $pdebug }
418
+ def list_named_blocks_in_file(call_options = {}, &options_block)
419
+ opts = optsmerge call_options, options_block
420
+ block_name_excluded_match = Regexp.new opts[:block_name_excluded_match]
421
+ list_blocks_in_file(opts).map do |block|
422
+ next if opts[:hide_blocks_by_name] && block[:name].match(block_name_excluded_match)
423
+
424
+ block
425
+ end.compact.tap_inspect
194
426
  end
195
427
 
196
428
  def list_recursively_required_blocks(table, name)
197
429
  name_block = get_block_by_name(table, name)
430
+ raise "Named code block `#{name}` not found." if name_block.nil? || name_block.keys.empty?
431
+
198
432
  all = [name_block[:name]] + recursively_required(table, name_block[:reqs])
199
433
 
200
434
  # in order of appearance in document
201
435
  table.select { |block| all.include? block[:name] }
202
436
  .map { |block| block.fetch(:body, '') }
203
437
  .flatten(1)
204
- .tap { |ret| puts "list_recursively_required_blocks() ret: #{ret.inspect}" if $pdebug }
438
+ .tap_inspect
439
+ end
440
+
441
+ def list_recent_scripts
442
+ Dir.glob(File.join(@options[:saved_script_folder],
443
+ @options[:saved_script_glob])).sort[0..(options[:list_count] - 1)].reverse.tap_inspect
205
444
  end
206
445
 
207
446
  def make_block_label(block, call_options = {})
@@ -217,27 +456,52 @@ module MarkdownExec
217
456
  def make_block_labels(call_options = {})
218
457
  opts = options.merge(call_options)
219
458
  list_blocks_in_file(opts).map do |block|
459
+ # next if opts[:hide_blocks_by_name] && block[:name].match(%r{^:\(.+\)$})
460
+
220
461
  make_block_label block, opts
462
+ end.compact.tap_inspect
463
+ end
464
+
465
+ def option_exclude_blocks(opts, blocks)
466
+ block_name_excluded_match = Regexp.new opts[:block_name_excluded_match]
467
+ if opts[:hide_blocks_by_name]
468
+ blocks.reject { |block| block[:name].match(block_name_excluded_match) }
469
+ else
470
+ blocks
221
471
  end
222
472
  end
223
473
 
224
474
  def optsmerge(call_options = {}, options_block = nil)
225
- class_call_options = options.merge(call_options || {})
475
+ class_call_options = @options.merge(call_options || {})
226
476
  if options_block
227
477
  options_block.call class_call_options
228
478
  else
229
479
  class_call_options
230
- end.tap { |ret| puts "optsmerge() ret: #{ret.inspect}" if $pdebug }
480
+ end.tap_inspect
481
+ end
482
+
483
+ def output_execution_summary
484
+ return unless @options[:output_execution_summary]
485
+
486
+ fout_section 'summary', {
487
+ execute_aborted_at: @execute_aborted_at,
488
+ execute_completed_at: @execute_completed_at,
489
+ execute_error: @execute_error,
490
+ execute_error_message: @execute_error_message,
491
+ execute_files: @execute_files,
492
+ execute_options: @execute_options,
493
+ execute_started_at: @execute_started_at,
494
+ execute_script_filespec: @execute_script_filespec
495
+ }
231
496
  end
232
497
 
233
498
  def read_configuration_file!(options, configuration_path)
234
- if File.exist?(configuration_path)
235
- # rubocop:disable Security/YAMLLoad
236
- options.merge!((YAML.load(File.open(configuration_path)) || {})
237
- .transform_keys(&:to_sym))
238
- # rubocop:enable Security/YAMLLoad
239
- end
240
- options
499
+ return unless File.exist?(configuration_path)
500
+
501
+ # rubocop:disable Security/YAMLLoad
502
+ options.merge!((YAML.load(File.open(configuration_path)) || {})
503
+ .transform_keys(&:to_sym))
504
+ # rubocop:enable Security/YAMLLoad
241
505
  end
242
506
 
243
507
  def recursively_required(table, reqs)
@@ -252,218 +516,235 @@ module MarkdownExec
252
516
  end
253
517
  .compact
254
518
  .flatten(1)
255
- .tap { |_ret| puts "recursively_required() rem: #{rem.inspect}" if $pdebug }
519
+ .tap_inspect(name: 'rem')
256
520
  end
257
- all.tap { |ret| puts "recursively_required() ret: #{ret.inspect}" if $pdebug }
521
+ all.tap_inspect
258
522
  end
259
523
 
260
524
  def run
261
525
  ## default configuration
262
526
  #
263
- options = {
264
- mdheadings: true,
265
- list_blocks: false,
266
- list_docs: false
267
- }
527
+ @options = base_options
268
528
 
269
529
  ## post-parse options configuration
270
530
  #
271
531
  options_finalize = ->(_options) {}
272
532
 
533
+ proc_self = ->(value) { value }
534
+ proc_to_i = ->(value) { value.to_i != 0 }
535
+ proc_true = ->(_) { true }
536
+
273
537
  # read local configuration file
274
538
  #
275
- read_configuration_file! options, ".#{MarkdownExec::APP_NAME.downcase}.yml"
539
+ read_configuration_file! @options, ".#{MarkdownExec::APP_NAME.downcase}.yml"
276
540
 
277
541
  option_parser = OptionParser.new do |opts|
278
542
  executable_name = File.basename($PROGRAM_NAME)
279
543
  opts.banner = [
280
- "#{MarkdownExec::APP_NAME} - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
281
- "Usage: #{executable_name} [options]"
544
+ "#{MarkdownExec::APP_NAME}" \
545
+ " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
546
+ "Usage: #{executable_name} [path] [filename] [options]"
282
547
  ].join("\n")
283
548
 
284
- ## menu top: items appear in reverse order added
285
- #
286
- opts.on('--config PATH', 'Read configuration file') do |value|
287
- read_configuration_file! options, value
288
- end
289
-
290
- ## menu body: items appear in order added
291
- #
292
- opts.on('-f RELATIVE', '--filename', 'Name of document') do |value|
293
- options[:filename] = value
294
- end
295
-
296
- opts.on('-p PATH', '--folder', 'Path to documents') do |value|
297
- options[:folder] = value
298
- end
299
-
300
- opts.on('--list-blocks', 'List blocks') do |_value|
301
- options[:list_blocks] = true
302
- end
303
-
304
- opts.on('--list-docs', 'List docs in current folder') do |_value|
305
- options[:list_docs] = true
306
- end
307
-
308
- ## menu bottom: items appear in order added
309
- #
310
- opts.on_tail('-h', '--help', 'App help') do |_value|
311
- fout option_parser.help
312
- exit
313
- end
314
-
315
- opts.on_tail('-v', '--version', 'App version') do |_value|
316
- fout MarkdownExec::VERSION
317
- exit
318
- end
319
-
320
- opts.on_tail('-x', '--exit', 'Exit app') do |_value|
321
- exit
322
- end
323
-
324
- opts.on_tail('-0', 'Show configuration') do |_v|
325
- options_finalize.call options
326
- fout options.to_yaml
549
+ summary_head = [
550
+ ['config', nil, nil, 'PATH', 'Read configuration file',
551
+ nil, ->(value) { read_configuration_file! options, value }],
552
+ ['debug', 'd', 'MDE_DEBUG', 'BOOL', 'Debug output',
553
+ nil, ->(value) { $pdebug = value.to_i != 0 }]
554
+ ]
555
+
556
+ summary_body = [
557
+ ['filename', 'f', 'MDE_FILENAME', 'RELATIVE', 'Name of document',
558
+ :filename, proc_self],
559
+ ['list-blocks', nil, nil, nil, 'List blocks',
560
+ :list_blocks, proc_true],
561
+ ['list-docs', nil, nil, nil, 'List docs in current folder',
562
+ :list_docs, proc_true],
563
+ ['list-recent-scripts', nil, nil, nil, 'List recent saved scripts',
564
+ :list_recent_scripts, proc_true],
565
+ ['output-execution-summary', nil, 'MDE_OUTPUT_EXECUTION_SUMMARY', 'BOOL', 'Display summary for execution',
566
+ :output_execution_summary, proc_to_i],
567
+ ['output-script', nil, 'MDE_OUTPUT_SCRIPT', 'BOOL', 'Display script',
568
+ :output_script, proc_to_i],
569
+ ['output-stdout', nil, 'MDE_OUTPUT_STDOUT', 'BOOL', 'Display standard output from execution',
570
+ :output_stdout, proc_to_i],
571
+ ['path', 'p', 'MDE_PATH', 'PATH', 'Path to documents',
572
+ :path, proc_self],
573
+ ['run-last-script', nil, nil, nil, 'Run most recently saved script',
574
+ :run_last_script, proc_true],
575
+ ['select-recent-script', nil, nil, nil, 'Select and execute a recently saved script',
576
+ :select_recent_script, proc_true],
577
+ ['save-execution-output', nil, 'MDE_SAVE_EXECUTION_OUTPUT', 'BOOL', 'Save execution output',
578
+ :save_execution_output, proc_to_i],
579
+ ['save-executed-script', nil, 'MDE_SAVE_EXECUTED_SCRIPT', 'BOOL', 'Save executed script',
580
+ :save_executed_script, proc_to_i],
581
+ ['saved-script-folder', nil, 'MDE_SAVED_SCRIPT_FOLDER', 'SPEC', 'Saved script folder',
582
+ :saved_script_folder, proc_self],
583
+ ['saved-stdout-folder', nil, 'MDE_SAVED_STDOUT_FOLDER', 'SPEC', 'Saved stdout folder',
584
+ :saved_stdout_folder, proc_self],
585
+ ['user-must-approve', nil, 'MDE_USER_MUST_APPROVE', 'BOOL', 'Pause to approve execution',
586
+ :user_must_approve, proc_to_i]
587
+ ]
588
+
589
+ # rubocop:disable Style/Semicolon
590
+ summary_tail = [
591
+ [nil, '0', nil, nil, 'Show configuration',
592
+ nil, ->(_) { options_finalize.call options; fout sorted_keys(options).to_yaml }],
593
+ ['help', 'h', nil, nil, 'App help',
594
+ nil, ->(_) { fout option_parser.help; exit }],
595
+ ['version', 'v', nil, nil, 'App version',
596
+ nil, ->(_) { fout MarkdownExec::VERSION; exit }],
597
+ ['exit', 'x', nil, nil, 'Exit app',
598
+ nil, ->(_) { exit }]
599
+ ]
600
+ # rubocop:enable Style/Semicolon
601
+
602
+ (summary_head + summary_body + summary_tail)
603
+ .map do |long_name, short_name, env_var, arg_name, description, opt_name, proc1| # rubocop:disable Metrics/ParameterLists
604
+ opts.on(*[if long_name.present?
605
+ "--#{long_name}#{arg_name.present? ? " #{arg_name}" : ''}"
606
+ end,
607
+ short_name.present? ? "-#{short_name}" : nil,
608
+ [description,
609
+ env_var.present? ? "env: #{env_var}" : nil].compact.join(' - '),
610
+ lambda { |value|
611
+ ret = proc1.call(value)
612
+ options[opt_name] = ret if opt_name
613
+ ret
614
+ }].compact)
327
615
  end
328
616
  end
329
617
  option_parser.load # filename defaults to basename of the program without suffix in a directory ~/.options
330
618
  option_parser.environment # env defaults to the basename of the program.
331
- option_parser.parse! # (into: options)
332
- options_finalize.call options
619
+ rest = option_parser.parse! # (into: options)
333
620
 
334
- ## process
621
+ ## finalize configuration
335
622
  #
336
- options.merge!(
337
- {
338
- approve: true,
339
- bash: true,
340
- display: true,
341
- exclude_expect_blocks: true,
342
- execute: true,
343
- prompt: 'Execute',
344
- struct: true
345
- }
346
- )
347
- mp = MarkParse.new options
623
+ options_finalize.call options
348
624
 
349
- ## show
625
+ ## position 0: file or folder (optional)
350
626
  #
351
- if options[:list_docs]
352
- fout mp.list_files_per_options options
353
- return
354
- end
355
-
356
- if options[:list_blocks]
357
- fout (mp.list_files_per_options(options).map do |file|
358
- mp.make_block_labels(filename: file, struct: true)
359
- end).flatten(1)
360
- return
361
- end
362
-
363
- mp.select_block(
364
- bash: true,
365
- filename: select_md_file(list_files_per_options(options)),
366
- struct: true
367
- )
368
- end
369
-
370
- def select_block(call_options = {}, &options_block)
371
- opts = optsmerge call_options, options_block
372
-
373
- blocks = list_blocks_in_file(opts.merge(struct: true))
374
-
375
- prompt = TTY::Prompt.new(interrupt: :exit)
376
- pt = "#{opts.fetch(:prompt, nil) || 'Pick one'}:"
377
-
378
- blocks.each { |block| block.merge! label: make_block_label(block, opts) }
379
- block_labels = blocks.map { |block| block[:label] }
380
-
381
- if opts[:preview_options]
382
- select_per_page = 3
383
- block_labels.each do |bn|
384
- fout " - #{bn}"
627
+ if (pos = rest.fetch(0, nil))&.present?
628
+ if Dir.exist?(pos)
629
+ options[:path] = pos
630
+ elsif File.exist?(pos)
631
+ options[:filename] = pos
632
+ else
633
+ raise "Invalid parameter: #{pos}"
385
634
  end
386
- else
387
- select_per_page = SELECT_PAGE_HEIGHT
388
635
  end
389
636
 
390
- return nil if block_labels.count.zero?
391
-
392
- sel = prompt.select(pt, block_labels, per_page: select_per_page)
393
-
394
- label_block = blocks.select { |block| block[:label] == sel }.fetch(0, nil)
395
- sel = label_block[:name]
396
-
397
- cbs = list_recursively_required_blocks(blocks, sel)
398
-
399
- ## display code blocks for approval
637
+ ## position 1: block name (optional)
400
638
  #
401
- cbs.each { |cb| fout cb } if opts[:display] || opts[:approve]
402
-
403
- allow = true
404
- allow = prompt.yes? 'Process?' if opts[:approve]
639
+ block_name = rest.fetch(1, nil)
405
640
 
406
- selected = get_block_by_name blocks, sel
407
- if allow && opts[:execute]
641
+ exec_block options, block_name
642
+ end
408
643
 
409
- ## process in script, to handle line continuations
410
- #
411
- cmd2 = cbs.flatten.join("\n")
644
+ def run_last_script
645
+ filename = Dir.glob(File.join(@options[:saved_script_folder],
646
+ @options[:saved_script_glob])).sort[0..(options[:list_count] - 1)].last
647
+ filename.tap_inspect name: filename
648
+ mf = filename.match(/#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_(?<block>.+)\.sh/)
649
+
650
+ @options[:block_name] = mf[:block]
651
+ @options[:filename] = "#{mf[:file]}.md" ### other extensions
652
+ @options[:save_executed_script] = false
653
+ select_and_approve_block
654
+ save_execution_output
655
+ output_execution_summary
656
+ end
412
657
 
413
- Open3.popen3(cmd2) do |stdin, stdout, stderr|
414
- stdin.close_write
415
- begin
416
- files = [stdout, stderr]
658
+ def save_execution_output
659
+ return unless @options[:save_execution_output]
660
+
661
+ fne = File.basename(@options[:filename], '.*')
662
+ @options[:logged_stdout_filename] =
663
+ "#{[@options[:logged_stdout_filename_prefix], Time.now.utc.strftime('%F-%H-%M-%S'), fne,
664
+ @options[:block_name]].join('_')}.out.txt"
665
+ @options[:logged_stdout_filespec] = File.join @options[:saved_stdout_folder], @options[:logged_stdout_filename]
666
+ @logged_stdout_filespec = @options[:logged_stdout_filespec]
667
+ dirname = File.dirname(@options[:logged_stdout_filespec])
668
+ Dir.mkdir dirname unless File.exist?(dirname)
669
+ File.write(@options[:logged_stdout_filespec], @execute_files&.fetch(0, ''))
670
+ end
417
671
 
418
- until all_at_eof(files)
419
- ready = IO.select(files)
672
+ def select_and_approve_block(call_options = {}, &options_block)
673
+ opts = optsmerge call_options, options_block
674
+ blocks_in_file = list_blocks_in_file(opts.merge(struct: true))
420
675
 
421
- next unless ready
676
+ unless opts[:block_name].present?
677
+ pt = (opts[:prompt_select_block]).to_s
678
+ blocks_in_file.each { |block| block.merge! label: make_block_label(block, opts) }
679
+ block_labels = option_exclude_blocks(opts, blocks_in_file).map { |block| block[:label] }
422
680
 
423
- readable = ready[0]
424
- # writable = ready[1]
425
- # exceptions = ready[2]
681
+ return nil if block_labels.count.zero?
426
682
 
427
- readable.each do |f|
428
- print f.read_nonblock(BLOCK_SIZE)
429
- rescue EOFError #=> e
430
- # do nothing at EOF
431
- end
432
- end
433
- rescue IOError => e
434
- fout "IOError: #{e}"
435
- end
436
- end
683
+ sel = @prompt.select pt, block_labels, per_page: opts[:select_page_height]
684
+ label_block = blocks_in_file.select { |block| block[:label] == sel }.fetch(0, nil)
685
+ opts[:block_name] = @options[:block_name] = label_block[:name]
437
686
  end
438
687
 
439
- selected[:name]
688
+ approve_block opts, blocks_in_file
440
689
  end
441
690
 
442
691
  def select_md_file(files_ = nil)
443
692
  opts = options
444
- files = files_ || list_markdown_files_in_folder
693
+ files = files_ || list_markdown_files_in_path
445
694
  if files.count == 1
446
- sel = files[0]
695
+ files[0]
447
696
  elsif files.count >= 2
697
+ @prompt.select opts[:prompt_select_md].to_s, files, per_page: opts[:select_page_height]
698
+ end
699
+ end
448
700
 
449
- if opts[:preview_options]
450
- select_per_page = 3
451
- files.each do |file|
452
- fout " - #{file}"
453
- end
454
- else
455
- select_per_page = SELECT_PAGE_HEIGHT
456
- end
701
+ def select_recent_script
702
+ filename = @prompt.select @options[:prompt_select_md].to_s, list_recent_scripts,
703
+ per_page: @options[:select_page_height]
704
+ mf = filename.match(/#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_(?<block>.+)\.sh/)
457
705
 
458
- prompt = TTY::Prompt.new
459
- sel = prompt.select("#{opts.fetch(:prompt, 'Pick one')}:", files, per_page: select_per_page)
460
- end
706
+ @options[:block_name] = mf[:block]
707
+ @options[:filename] = "#{mf[:file]}.md" ### other extensions
708
+ select_and_approve_block(
709
+ bash: true,
710
+ save_executed_script: false,
711
+ struct: true
712
+ )
713
+ save_execution_output
714
+ output_execution_summary
715
+ end
461
716
 
462
- sel
717
+ def sorted_keys(hash1)
718
+ hash1.keys.sort.to_h { |k| [k, hash1[k]] }
463
719
  end
464
720
 
465
721
  def summarize_block(headings, title)
466
722
  { headings: headings, name: title, title: title }
467
723
  end
724
+
725
+ def update_options(opts = {}, over: true)
726
+ if over
727
+ @options = @options.merge opts
728
+ else
729
+ @options.merge! opts
730
+ end
731
+ @options
732
+ end
733
+
734
+ def write_command_file(opts, required_blocks)
735
+ fne = File.basename(opts[:filename], '.*')
736
+ opts[:saved_script_filename] =
737
+ "#{[opts[:saved_script_filename_prefix], Time.now.utc.strftime('%F-%H-%M-%S'), fne,
738
+ opts[:block_name]].join('_')}.sh"
739
+ @options[:saved_filespec] = File.join opts[:saved_script_folder], opts[:saved_script_filename]
740
+ @execute_script_filespec = @options[:saved_filespec]
741
+ dirname = File.dirname(@options[:saved_filespec])
742
+ Dir.mkdir dirname unless File.exist?(dirname)
743
+ File.write(@options[:saved_filespec], "#!/usr/bin/env bash\n" \
744
+ "# file_name: #{opts[:filename]}\n" \
745
+ "# block_name: #{opts[:block_name]}\n" \
746
+ "# time: #{Time.now.utc}\n" \
747
+ "#{required_blocks.flatten.join("\n")}\n")
748
+ end
468
749
  end
469
750
  end