na 1.2.87 → 1.2.88

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/bin/commands/next.rb CHANGED
@@ -83,6 +83,22 @@ class App
83
83
  c.desc "Include @done actions"
84
84
  c.switch %i[done]
85
85
 
86
+ c.desc "Run a plugin on results (STDOUT only; no file writes)"
87
+ c.arg_name 'NAME'
88
+ c.flag %i[plugin]
89
+
90
+ c.desc 'Plugin input format (json|yaml|csv|text)'
91
+ c.arg_name 'TYPE'
92
+ c.flag %i[input]
93
+
94
+ c.desc 'Plugin output format (json|yaml|csv|text)'
95
+ c.arg_name 'TYPE'
96
+ c.flag %i[output]
97
+
98
+ c.desc 'Divider string for text IO'
99
+ c.arg_name 'STRING'
100
+ c.flag %i[divider]
101
+
86
102
  c.desc "Output actions nested by file"
87
103
  c.switch %i[nest], negatable: false
88
104
 
@@ -262,6 +278,55 @@ class App
262
278
  Run `na todos` to see available todo files.")
263
279
  end
264
280
  NA::Pager.paginate = false if options[:omnifocus]
281
+
282
+ # If a plugin is specified, transform actions in memory for display only
283
+ if options[:plugin]
284
+ NA::Plugins.ensure_plugins_home
285
+ plugin_path = options[:plugin]
286
+ unless File.exist?(plugin_path)
287
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
288
+ plugin_path = resolved if resolved
289
+ end
290
+ if plugin_path && File.exist?(plugin_path)
291
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
292
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
293
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
294
+ divider = (options[:divider] || '||')
295
+
296
+ io_actions = todo.actions.map(&:to_plugin_io_hash)
297
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
298
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
299
+ returned = Array(NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider))
300
+ index = {}
301
+ todo.actions.each { |a| index["#{a.file_path}:#{a.file_line}"] = a }
302
+ returned.each do |h|
303
+ key = "#{h['file_path']}:#{h['line'].to_i}"
304
+ a = index[key]
305
+ next unless a
306
+ # Update for display: text, note, tags
307
+ new_text = h['text'].to_s
308
+ new_note = h['note'].to_s
309
+ new_tags = Array(h['tags']).map { |t| [t['name'].to_s, t['value'].to_s] }
310
+ # replace tags in text
311
+ new_text = new_text.gsub(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
312
+ unless new_tags.empty?
313
+ tag_str = new_tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
314
+ new_text = new_text.strip + (tag_str.empty? ? '' : " #{tag_str}")
315
+ end
316
+ a.action = new_text
317
+ a.note = new_note.empty? ? [] : new_note.split("\n")
318
+ a.instance_variable_set(:@tags, a.scan_tags)
319
+ # parents -> possibly change project and parent chain for display
320
+ parents = Array(h['parents']).map(&:to_s)
321
+ if parents.any?
322
+ new_proj = parents.first.to_s
323
+ new_chain = parents[1..] || []
324
+ a.instance_variable_set(:@project, new_proj)
325
+ a.parent = new_chain
326
+ end
327
+ end
328
+ end
329
+ end
265
330
  todo.actions.output(depth,
266
331
  { files: todo.files,
267
332
  nest: options[:nest],
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ class App
4
+ extend GLI::App
5
+
6
+ desc 'Run a plugin on selected actions'
7
+ arg_name 'NAME'
8
+ command %i[plugin] do |c|
9
+ c.desc 'Input format (json|yaml|text)'
10
+ c.arg_name 'TYPE'
11
+ c.flag %i[input]
12
+
13
+ c.desc 'Output format (json|yaml|text)'
14
+ c.arg_name 'TYPE'
15
+ c.flag %i[output]
16
+
17
+ c.desc 'Text divider when using --input/--output text'
18
+ c.arg_name 'STRING'
19
+ c.flag %i[divider]
20
+
21
+ c.desc 'Specify the file to search for the task'
22
+ c.arg_name 'PATH'
23
+ c.flag %i[file in]
24
+
25
+ c.desc 'Search for files X directories deep'
26
+ c.arg_name 'DEPTH'
27
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
28
+
29
+ c.desc 'Filter results using search terms'
30
+ c.arg_name 'QUERY'
31
+ c.flag %i[search find grep], multiple: true
32
+
33
+ c.desc 'Include @done actions'
34
+ c.switch %i[done]
35
+
36
+ c.desc 'Match actions containing tag. Allows value comparisons'
37
+ c.arg_name 'TAG'
38
+ c.flag %i[tagged], multiple: true
39
+
40
+ c.action do |_global, options, args|
41
+ plugin_name = args.first
42
+ NA.notify("#{NA.theme[:error]}Plugin name required", exit_code: 1) unless plugin_name
43
+
44
+ NA::Plugins.ensure_plugins_home
45
+ path = NA::Plugins.resolve_plugin(plugin_name)
46
+ NA.notify("#{NA.theme[:error]}Plugin not found: #{plugin_name}", exit_code: 1) unless path
47
+
48
+ meta = NA::Plugins.parse_plugin_metadata(path)
49
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
50
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
51
+ divider = (options[:divider] || '||')
52
+
53
+ # Build selection using the same plumbing as update/find
54
+ actions = NA.select_actions(
55
+ file: options[:file],
56
+ depth: options[:depth],
57
+ search: options[:search],
58
+ tagged: options[:tagged],
59
+ include_done: options[:done]
60
+ )
61
+
62
+ io_actions = actions.map(&:to_plugin_io_hash)
63
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
64
+ stdout = NA::Plugins.run_plugin(path, stdin_str)
65
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
66
+
67
+ # Apply updates
68
+ returned.each do |h|
69
+ NA.apply_plugin_result(h)
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+
@@ -83,6 +83,22 @@ class App
83
83
  c.desc 'Output actions nested by file and project'
84
84
  c.switch %i[omnifocus], negatable: false
85
85
 
86
+ c.desc 'Run a plugin on results (STDOUT only; no file writes)'
87
+ c.arg_name 'NAME'
88
+ c.flag %i[plugin]
89
+
90
+ c.desc 'Plugin input format (json|yaml|csv|text)'
91
+ c.arg_name 'TYPE'
92
+ c.flag %i[input]
93
+
94
+ c.desc 'Plugin output format (json|yaml|csv|text)'
95
+ c.arg_name 'TYPE'
96
+ c.flag %i[output]
97
+
98
+ c.desc 'Divider string for text IO'
99
+ c.arg_name 'STRING'
100
+ c.flag %i[divider]
101
+
86
102
  c.action do |global_options, options, args|
87
103
  options[:nest] = true if options[:omnifocus]
88
104
 
@@ -184,6 +200,53 @@ class App
184
200
  else
185
201
  [tokens]
186
202
  end
203
+
204
+ # Plugin piping (display only)
205
+ if options[:plugin]
206
+ NA::Plugins.ensure_plugins_home
207
+ plugin_path = options[:plugin]
208
+ unless File.exist?(plugin_path)
209
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
210
+ plugin_path = resolved if resolved
211
+ end
212
+ if plugin_path && File.exist?(plugin_path)
213
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
214
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
215
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
216
+ divider = (options[:divider] || '||')
217
+
218
+ io_actions = todo.actions.map(&:to_plugin_io_hash)
219
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
220
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
221
+ returned = Array(NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider))
222
+ index = {}
223
+ todo.actions.each { |a| index["#{a.file_path}:#{a.file_line}"] = a }
224
+ returned.each do |h|
225
+ key = "#{h['file_path']}:#{h['line'].to_i}"
226
+ a = index[key]
227
+ next unless a
228
+ new_text = h['text'].to_s
229
+ new_note = h['note'].to_s
230
+ new_tags = Array(h['tags']).map { |t| [t['name'].to_s, t['value'].to_s] }
231
+ new_text = new_text.gsub(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
232
+ unless new_tags.empty?
233
+ tag_str = new_tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
234
+ new_text = new_text.strip + (tag_str.empty? ? '' : " #{tag_str}")
235
+ end
236
+ a.action = new_text
237
+ a.note = new_note.empty? ? [] : new_note.split("\n")
238
+ a.instance_variable_set(:@tags, a.scan_tags)
239
+ parents = Array(h['parents']).map(&:to_s)
240
+ if parents.any?
241
+ new_proj = parents.first.to_s
242
+ new_chain = parents[1..] || []
243
+ a.instance_variable_set(:@project, new_proj)
244
+ a.parent = new_chain
245
+ end
246
+ end
247
+ end
248
+ end
249
+
187
250
  todo.actions.output(depth,
188
251
  { files: todo.files,
189
252
  regexes: regexes,
@@ -9,6 +9,21 @@ class App
9
9
  allow you to pick which file to act on.'
10
10
  arg_name 'ACTION'
11
11
  command %i[update] do |c|
12
+ c.desc 'Run a plugin by name on selected actions'
13
+ c.arg_name 'NAME'
14
+ c.flag %i[plugin]
15
+
16
+ c.desc 'Plugin input format (json|yaml|csv|text)'
17
+ c.arg_name 'TYPE'
18
+ c.flag %i[input]
19
+
20
+ c.desc 'Plugin output format (json|yaml|csv|text)'
21
+ c.arg_name 'TYPE'
22
+ c.flag %i[output]
23
+
24
+ c.desc 'Divider string for text IO'
25
+ c.arg_name 'STRING'
26
+ c.flag %i[divider]
12
27
  c.desc 'Started time (natural language or ISO)'
13
28
  c.arg_name 'DATE'
14
29
  c.flag %i[started], type: :date_begin
@@ -329,6 +344,17 @@ class App
329
344
  { key: :archive, label: 'Archive', param: nil },
330
345
  { key: :note, label: 'Add Note', param: 'Note' }
331
346
  ]
347
+ # Append available plugins
348
+ begin
349
+ NA::Plugins.ensure_plugins_home
350
+ NA::Plugins.list_plugins.each do |_key, path|
351
+ meta = NA::Plugins.parse_plugin_metadata(path)
352
+ disp = meta['name'] || File.basename(path, File.extname(path))
353
+ actions_menu << { key: :_plugin, label: "Plugin: #{disp}", param: nil, plugin_path: path }
354
+ end
355
+ rescue StandardError
356
+ # ignore plugin discovery errors in menu
357
+ end
332
358
  selector = nil
333
359
  if TTY::Which.exist?('fzf')
334
360
  selector = 'fzf --prompt="Select action> "'
@@ -441,6 +467,9 @@ class App
441
467
  when :note
442
468
  options[:note] = true
443
469
  note = [param_value]
470
+ when :_plugin
471
+ # Set plugin path directly
472
+ options[:plugin] = action_obj[:plugin_path]
444
473
  end
445
474
  end
446
475
  did_direct_update = false
@@ -453,7 +482,31 @@ class App
453
482
  actions_by_file[file] << targets_for_selection[idx][:action]
454
483
  end
455
484
 
456
- # Process each file's actions
485
+ # If a plugin is specified, run it on all selected actions and apply results
486
+ if options[:plugin]
487
+ plugin_path = options[:plugin]
488
+ unless File.exist?(plugin_path)
489
+ # Resolve by name via registry
490
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
491
+ plugin_path = resolved if resolved
492
+ end
493
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
494
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
495
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
496
+ divider = (options[:divider] || '||')
497
+
498
+ all_actions = []
499
+ actions_by_file.each_value { |list| all_actions.concat(list) }
500
+ io_actions = all_actions.map(&:to_plugin_io_hash)
501
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
502
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
503
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
504
+ Array(returned).each { |h| NA.apply_plugin_result(h) }
505
+ did_direct_update = true
506
+ next
507
+ end
508
+
509
+ # Process each file's actions (non-plugin paths)
457
510
  actions_by_file.each do |file, action_list|
458
511
  # Rebuild all derived variables from options after menu-driven assignment
459
512
  add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
data/bin/na CHANGED
@@ -113,6 +113,7 @@ class App
113
113
 
114
114
  pre do |global, _command, _options, _args|
115
115
  NA.move_deprecated_backups
116
+ NA::Plugins.ensure_plugins_home
116
117
  NA.verbose = global[:debug]
117
118
  NA::Pager.paginate = global[:pager] && $stdout.isatty
118
119
  NA::Color.coloring = global[:color] && $stdout.isatty
data/lib/na/action.rb CHANGED
@@ -28,6 +28,19 @@ module NA
28
28
  @note = note
29
29
  end
30
30
 
31
+ # Convert action to plugin IO hash
32
+ # @return [Hash]
33
+ def to_plugin_io_hash
34
+ {
35
+ 'file_path' => file_path,
36
+ 'line' => file_line,
37
+ 'parents' => [@project].concat(@parent),
38
+ 'text' => @action.dup,
39
+ 'note' => @note.join("\n"),
40
+ 'tags' => @tags.map { |k, v| { 'name' => k, 'value' => (v || '').to_s } }
41
+ }
42
+ end
43
+
31
44
  # Extract file path and line number from PATH:LINE format
32
45
  #
33
46
  # @return [Array] [file_path, line_number]
@@ -3,6 +3,98 @@
3
3
  # Next Action methods
4
4
  module NA
5
5
  class << self
6
+ # Select actions across files using existing search pipeline
7
+ # @return [Array<NA::Action>]
8
+ def select_actions(file: nil, depth: 1, search: [], tagged: [], include_done: false)
9
+ files = if file
10
+ [file]
11
+ else
12
+ find_files(depth: depth)
13
+ end
14
+ out = []
15
+ files.each do |f|
16
+ _projects, actions = find_actions(f, search, tagged, done: include_done, all: true)
17
+ out.concat(actions) if actions
18
+ end
19
+ out
20
+ end
21
+
22
+ # Apply a plugin result hash back to the underlying file
23
+ # - Move if parents changed (project path differs)
24
+ # - Update text/note/tags
25
+ def apply_plugin_result(io_hash)
26
+ file = io_hash['file_path']
27
+ line = io_hash['line'].to_i
28
+ parents = Array(io_hash['parents']).map(&:to_s)
29
+ text = io_hash['text'].to_s
30
+ note = io_hash['note'].to_s
31
+ tags = Array(io_hash['tags']).to_h { |t| [t['name'].to_s, t['value'].to_s] }
32
+ action_block = io_hash['action'] || { 'action' => 'UPDATE', 'arguments' => [] }
33
+ action_name = action_block['action'].to_s.upcase
34
+ action_args = Array(action_block['arguments'])
35
+
36
+ # Load current action
37
+ _projects, actions = find_actions(file, nil, nil, all: true, done: true, project: nil, search_note: true, target_line: line)
38
+ action = actions&.first
39
+ return unless action
40
+
41
+ # Determine new project path from parents array
42
+ new_project = ''
43
+ new_parent_chain = []
44
+ if parents.any?
45
+ new_project = parents.first.to_s
46
+ new_parent_chain = parents[1..] || []
47
+ end
48
+
49
+ case action_name
50
+ when 'DELETE'
51
+ update_action(file, { target_line: line }, delete: true, all: true)
52
+ return
53
+ when 'COMPLETE'
54
+ update_action(file, { target_line: line }, finish: true, all: true)
55
+ return
56
+ when 'RESTORE'
57
+ update_action(file, { target_line: line }, restore: true, all: true)
58
+ return
59
+ when 'ARCHIVE'
60
+ update_action(file, { target_line: line }, finish: true, move: 'Archive', all: true)
61
+ return
62
+ when 'ADD_TAG'
63
+ add_tags = action_args.map { |t| t.sub(/^@/, '') }
64
+ update_action(file, { target_line: line }, add: action, add_tag: add_tags, all: true)
65
+ return
66
+ when 'DELETE_TAG', 'REMOVE_TAG'
67
+ remove_tags = action_args.map { |t| t.sub(/^@/, '') }
68
+ update_action(file, { target_line: line }, add: action, remove_tag: remove_tags, all: true)
69
+ return
70
+ when 'MOVE'
71
+ move_to = action_args.first.to_s
72
+ update_action(file, { target_line: line }, add: action, move: move_to, all: true)
73
+ return
74
+ end
75
+
76
+ # Replace content on the existing action then write back in-place
77
+ original_line = action.file_line
78
+ action.action = text
79
+ action.note = note.to_s.split("\n")
80
+ action.action.gsub!(/(?<=\A| )@\S+(?:\(.*?\))?/, '')
81
+ unless tags.empty?
82
+ tag_str = tags.map { |k, v| v.to_s.empty? ? "@#{k}" : "@#{k}(#{v})" }.join(' ')
83
+ action.action = action.action.strip + (tag_str.empty? ? "" : " #{tag_str}")
84
+ end
85
+ # Ensure we update this exact action in-place
86
+ update_action(file, { target_line: original_line }, add: action, all: true)
87
+
88
+ # If parents changed, set move target
89
+ move_to = nil
90
+ move_to = ([new_project] + new_parent_chain).join(':') if new_project.to_s.strip != action.project || new_parent_chain != action.parent
91
+
92
+ update_action(file, nil,
93
+ add: action,
94
+ project: action.project,
95
+ overwrite: true,
96
+ move: move_to)
97
+ end
6
98
  include NA::Editor
7
99
 
8
100
  attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,