markdown_exec 0.2.0 → 0.2.4

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/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