na 1.2.87 → 1.2.89

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.
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ class App
4
+ extend GLI::App
5
+
6
+ desc 'Manage and run plugins'
7
+ command %i[plugin] do |c|
8
+ c.desc "Regenerate sample plugins and README"
9
+ c.switch %i[generate-examples]
10
+
11
+ c.action do |_global, options, args|
12
+ # If --generate-examples flag is used
13
+ if options[:generate_examples]
14
+ NA::Plugins.generate_sample_plugins
15
+ readme = File.join(NA::Plugins.plugins_home, 'README.md')
16
+ File.write(readme, NA::Plugins.default_readme_contents)
17
+ NA.notify("#{NA.theme[:success]}Sample plugins and README regenerated")
18
+ elsif args.any? && !args.first.match?(/^(new|n|edit|run|x|enable|e|disable|d|list|ls)$/i)
19
+ # If first argument is not a recognized subcommand, treat it as a plugin name
20
+ # and default to 'run' command
21
+ # Note: GLI will route to subcommands first, so this only triggers if no subcommand matches
22
+ # Note: This shortcut doesn't support run command flags - use 'na plugin run NAME' for flags
23
+ plugin_name = args.first
24
+ path = NA::Plugins.resolve_plugin(plugin_name)
25
+ if path
26
+ # Execute run logic inline with defaults (no flags supported in shortcut)
27
+ NA::Plugins.ensure_plugins_home
28
+ meta = NA::Plugins.parse_plugin_metadata(path)
29
+ input_fmt = (meta['input'] || 'json').to_s
30
+ output_fmt = (meta['output'] || input_fmt).to_s
31
+ divider = '||'
32
+
33
+ # No filters for shortcut - always shows interactive menu
34
+ files = NA.find_files(depth: 1)
35
+ options_list = []
36
+ selection = []
37
+ files.each do |f|
38
+ todo = NA::Todo.new(file_path: f, done: false, require_na: false)
39
+ todo.actions.each do |a|
40
+ options_list << "#{File.basename(a.file_path)}:#{a.file_line}:#{a.parent.join('>')} | #{a.action}"
41
+ selection << a
42
+ end
43
+ end
44
+ NA.notify("#{NA.theme[:error]}No actions found", exit_code: 1) if options_list.empty?
45
+ chosen = NA.choose_from(options_list, prompt: 'Select actions to run plugin on', multiple: true)
46
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless chosen && !chosen.empty?
47
+ idxs = Array(chosen).map { |label| options_list.index(label) }.compact
48
+ actions = idxs.map { |i| selection[i] }
49
+
50
+ io_actions = actions.map(&:to_plugin_io_hash)
51
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
52
+ stdout = NA::Plugins.run_plugin(path, stdin_str)
53
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
54
+ Array(returned).each { |h| NA.apply_plugin_result(h) }
55
+ else
56
+ NA.notify("#{NA.theme[:error]}Unknown plugin command or plugin name: #{plugin_name}", exit_code: 1)
57
+ end
58
+ end
59
+ end
60
+
61
+ c.desc 'Create a new plugin'
62
+ c.arg_name 'NAME'
63
+ c.command %i[new n] do |cc|
64
+ cc.desc 'Language/ext (e.g. rb, py, /usr/bin/env bash)'
65
+ cc.arg_name 'LANG'
66
+ cc.flag %i[language lang]
67
+ cc.action do |_g, opts, args|
68
+ NA::Plugins.ensure_plugins_home
69
+ name = args.first
70
+ NA.notify("#{NA.theme[:error]}Plugin name required", exit_code: 1) unless name
71
+ file = NA::Plugins.create_plugin(name, language: opts[:language])
72
+ NA.notify("#{NA.theme[:success]}Created #{NA.theme[:filename]}#{file}")
73
+ NA.os_open(file)
74
+ end
75
+ end
76
+
77
+ c.desc 'Edit an existing plugin'
78
+ c.arg_name 'NAME'
79
+ c.command %i[edit] do |cc|
80
+ cc.action do |_g, _o, args|
81
+ NA::Plugins.ensure_plugins_home
82
+ target = args.first
83
+ unless target
84
+ all = NA::Plugins.list_plugins.merge(NA::Plugins.list_plugins_disabled)
85
+ names = all.values.map { |p| File.basename(p) }
86
+ chosen = NA.choose_from(names, prompt: 'Select plugin to edit', multiple: false)
87
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless chosen
88
+ target = chosen
89
+ end
90
+ path = NA::Plugins.resolve_plugin(target) || File.join(NA::Plugins.plugins_home, target)
91
+ NA.notify("#{NA.theme[:error]}Plugin not found: #{target}", exit_code: 1) unless File.exist?(path)
92
+ NA.os_open(path)
93
+ end
94
+ end
95
+
96
+ c.desc 'List available plugins'
97
+ c.command %i[list ls] do |cc|
98
+ cc.desc 'Filter by type: enabled or disabled (or e/d)'
99
+ cc.arg_name 'TYPE'
100
+ cc.flag %i[type t]
101
+ cc.action do |_g, options, _a|
102
+ NA::Plugins.ensure_plugins_home
103
+ enabled = NA::Plugins.list_plugins
104
+ disabled = NA::Plugins.list_plugins_disabled
105
+
106
+ # If --type is specified, output plain text only
107
+ if options[:type]
108
+ type_arg = options[:type].to_s.downcase
109
+ if type_arg.start_with?("e")
110
+ # Enabled only, plain text
111
+ enabled.each_value do |path|
112
+ basename = File.basename(path)
113
+ meta = NA::Plugins.parse_plugin_metadata(path)
114
+ display_name = meta['name'] || meta['title'] || basename
115
+ puts display_name
116
+ end
117
+ elsif type_arg.start_with?('d')
118
+ # Disabled only, plain text
119
+ disabled.each_value do |path|
120
+ basename = File.basename(path)
121
+ meta = NA::Plugins.parse_plugin_metadata(path)
122
+ display_name = meta['name'] || meta['title'] || basename
123
+ puts display_name
124
+ end
125
+ else
126
+ NA.notify("#{NA.theme[:error]}Invalid type: #{options[:type]}. Use 'enabled' or 'disabled' (or 'e'/'d')", exit_code: 1)
127
+ end
128
+ else
129
+ # Default formatted output
130
+ output = []
131
+ output << 'Available Plugins'
132
+ output << ''
133
+ output << '-' * 12
134
+ output << ''
135
+
136
+ if enabled.any?
137
+ output << NA::Color.template('{bg}Enabled:{x}')
138
+ enabled.each_value do |path|
139
+ basename = File.basename(path)
140
+ meta = NA::Plugins.parse_plugin_metadata(path)
141
+ display_name = meta['name'] || meta['title'] || basename
142
+ output << NA::Color.template("{x}- {by}#{display_name}{x}")
143
+ end
144
+ output << ''
145
+ end
146
+
147
+ if disabled.any?
148
+ output << NA::Color.template('{br}Disabled:{x}')
149
+ disabled.each_value do |path|
150
+ basename = File.basename(path)
151
+ meta = NA::Plugins.parse_plugin_metadata(path)
152
+ display_name = meta['name'] || meta['title'] || basename
153
+ output << NA::Color.template("{x}- {by}#{display_name}{x}")
154
+ end
155
+ output << ''
156
+ end
157
+
158
+ output << NA::Color.template("{x}No plugins found.{x}") if enabled.empty? && disabled.empty?
159
+
160
+ puts output.join("\n")
161
+ end
162
+ end
163
+ end
164
+
165
+ c.desc 'Run a plugin on selected actions'
166
+ c.arg_name 'NAME'
167
+ c.command %i[run x] do |cc|
168
+ cc.desc 'Input format (json|yaml|csv|text)'
169
+ cc.arg_name 'TYPE'
170
+ cc.flag %i[input]
171
+
172
+ cc.desc 'Output format (json|yaml|csv|text)'
173
+ cc.arg_name 'TYPE'
174
+ cc.flag %i[output]
175
+
176
+ cc.desc 'Text divider when using --input/--output text'
177
+ cc.arg_name 'STRING'
178
+ cc.flag %i[divider]
179
+
180
+ cc.desc 'Specify the file to search for the task'
181
+ cc.arg_name 'PATH'
182
+ cc.flag %i[file in]
183
+
184
+ cc.desc 'Search for files X directories deep'
185
+ cc.arg_name 'DEPTH'
186
+ cc.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
187
+
188
+ cc.desc 'Filter results using search terms'
189
+ cc.arg_name 'QUERY'
190
+ cc.flag %i[search find grep], multiple: true
191
+
192
+ cc.desc 'Include @done actions'
193
+ cc.switch %i[done]
194
+
195
+ cc.desc 'Match actions containing tag. Allows value comparisons'
196
+ cc.arg_name 'TAG'
197
+ cc.flag %i[tagged], multiple: true
198
+
199
+ cc.action do |_global, options, args|
200
+ NA::Plugins.ensure_plugins_home
201
+ plugin_name = args.first
202
+ unless plugin_name
203
+ names = NA::Plugins.list_plugins.values.map { |p| File.basename(p) }
204
+ plugin_name = NA.choose_from(names, prompt: 'Select plugin to run', multiple: false)
205
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless plugin_name
206
+ end
207
+ path = NA::Plugins.resolve_plugin(plugin_name)
208
+ NA.notify("#{NA.theme[:error]}Plugin not found: #{plugin_name}", exit_code: 1) unless path
209
+
210
+ meta = NA::Plugins.parse_plugin_metadata(path)
211
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
212
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
213
+ divider = (options[:divider] || '||')
214
+
215
+ # Normalize empty arrays to nil for proper "no filter" detection
216
+ search_filter = (options[:search] && !options[:search].empty?) ? options[:search] : nil
217
+ tagged_filter = (options[:tagged] && !options[:tagged].empty?) ? options[:tagged] : nil
218
+ file_filter = options[:file]
219
+
220
+ # If no filters provided, show menu immediately
221
+ if !file_filter && !search_filter && !tagged_filter
222
+ files = NA.find_files(depth: options[:depth] || 1)
223
+ options_list = []
224
+ selection = []
225
+ files.each do |f|
226
+ todo = NA::Todo.new(file_path: f, done: options[:done], require_na: false)
227
+ todo.actions.each do |a|
228
+ options_list << "#{File.basename(a.file_path)}:#{a.file_line}:#{a.parent.join('>')} | #{a.action}"
229
+ selection << a
230
+ end
231
+ end
232
+ NA.notify("#{NA.theme[:error]}No actions found", exit_code: 1) if options_list.empty?
233
+ chosen = NA.choose_from(options_list, prompt: 'Select actions to run plugin on', multiple: true)
234
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless chosen && !chosen.empty?
235
+ idxs = Array(chosen).map { |label| options_list.index(label) }.compact
236
+ actions = idxs.map { |i| selection[i] }
237
+ else
238
+ # Use filters to find actions
239
+ actions = NA.select_actions(
240
+ file: file_filter,
241
+ depth: options[:depth],
242
+ search: search_filter,
243
+ tagged: tagged_filter,
244
+ include_done: options[:done]
245
+ )
246
+ NA.notify("#{NA.theme[:error]}No matching actions found", exit_code: 1) if actions.empty?
247
+ end
248
+
249
+ io_actions = actions.map(&:to_plugin_io_hash)
250
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
251
+ stdout = NA::Plugins.run_plugin(path, stdin_str)
252
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
253
+ Array(returned).each { |h| NA.apply_plugin_result(h) }
254
+ end
255
+ end
256
+
257
+ c.desc 'Enable a disabled plugin'
258
+ c.arg_name 'NAME'
259
+ c.command %i[enable e] do |cc|
260
+ cc.action do |_g, _o, args|
261
+ NA::Plugins.ensure_plugins_home
262
+ name = args.first
263
+ unless name
264
+ names = NA::Plugins.list_plugins_disabled.values.map { |p| File.basename(p) }
265
+ name = NA.choose_from(names, prompt: 'Enable which plugin?', multiple: false)
266
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless name
267
+ end
268
+ path = NA::Plugins.enable_plugin(name)
269
+ NA.notify("#{NA.theme[:success]}Enabled #{NA.theme[:filename]}#{File.basename(path)}")
270
+ end
271
+ end
272
+
273
+ c.desc 'Disable an enabled plugin'
274
+ c.arg_name 'NAME'
275
+ c.command %i[disable d] do |cc|
276
+ cc.action do |_g, _o, args|
277
+ NA::Plugins.ensure_plugins_home
278
+ name = args.first
279
+ unless name
280
+ names = NA::Plugins.list_plugins.values.map { |p| File.basename(p) }
281
+ name = NA.choose_from(names, prompt: 'Disable which plugin?', multiple: false)
282
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless name
283
+ end
284
+ path = NA::Plugins.disable_plugin(name)
285
+ NA.notify("#{NA.theme[:warning]}Disabled #{NA.theme[:filename]}#{File.basename(path)}")
286
+ end
287
+ end
288
+ end
289
+ end
@@ -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,22 @@ class App
329
344
  { key: :archive, label: 'Archive', param: nil },
330
345
  { key: :note, label: 'Add Note', param: 'Note' }
331
346
  ]
347
+ # Add "Run Plugin" option if there are enabled plugins with metadata
348
+ available_plugins = []
349
+ begin
350
+ NA::Plugins.ensure_plugins_home
351
+ NA::Plugins.list_plugins.each_value do |path|
352
+ meta = NA::Plugins.parse_plugin_metadata(path)
353
+ # Only include plugins with both input and output metadata
354
+ next unless meta['input'] && meta['output']
355
+
356
+ disp = meta['name'] || File.basename(path, File.extname(path))
357
+ available_plugins << { key: :_plugin, label: disp, param: nil, plugin_path: path }
358
+ end
359
+ actions_menu << { key: :run_plugin, label: 'Run Plugin', param: nil } if available_plugins.any?
360
+ rescue StandardError
361
+ # ignore plugin discovery errors in menu
362
+ end
332
363
  selector = nil
333
364
  if TTY::Which.exist?('fzf')
334
365
  selector = 'fzf --prompt="Select action> "'
@@ -340,107 +371,136 @@ class App
340
371
  if selector
341
372
  require 'open3'
342
373
  input = menu_labels.join("\n")
343
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
374
+ output, = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
344
375
  selected_action = output.strip
345
376
  else
346
377
  puts 'Select an action:'
347
- menu_labels.each_with_index { |label, i| puts "#{i+1}. #{label}" }
348
- idx = (STDIN.gets || '').strip.to_i - 1
378
+ menu_labels.each_with_index { |label, i| puts "#{i + 1}. #{label}" }
379
+ idx = ($stdin.gets || '').strip.to_i - 1
349
380
  selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
350
381
  end
351
382
  action_obj = actions_menu.find { |a| a[:label] == selected_action }
352
- if action_obj.nil?
353
- NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1)
354
- end
355
- # Prompt for parameter if needed
356
- param_value = nil
357
- # Only prompt for param if not :move (which has custom menu logic)
358
- if action_obj[:param] && action_obj[:key] != :move
359
- if TTY::Which.exist?('gum')
360
- gum = TTY::Which.which('gum')
361
- prompt = "Enter #{action_obj[:param]}: "
362
- param_value = `#{gum} input --placeholder "#{prompt}"`.strip
363
- else
364
- print "Enter #{action_obj[:param]}: "
365
- param_value = (STDIN.gets || '').strip
366
- end
367
- end
368
- # Set options for update
369
- case action_obj[:key]
370
- when :add_tag
371
- options[:tag] = [param_value]
372
- when :remove_tag
373
- options[:remove] = [param_value]
374
- when :delete
375
- options[:delete] = true
376
- when :finish
377
- options[:finish] = true
378
- # Timed finish? Prompt user for optional start/date inputs
379
- if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
380
- # Ask for start date expression
381
- start_expr = nil
382
- if TTY::Which.exist?('gum')
383
- gum = TTY::Which.which('gum')
384
- prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
385
- start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
386
- else
387
- print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
388
- start_expr = (STDIN.gets || '').strip
389
- end
390
- start_time = NA::Types.parse_date_begin(start_expr)
391
- options[:started] = start_time if start_time
392
- end
393
- when :edit
394
- # Just set the flag - multi-action editor will handle it below
395
- options[:edit] = true
396
- when :priority
397
- options[:priority] = param_value
398
- when :move
399
- # Gather projects from the same file as the selected action
400
- selected_file = targets_for_selection[selected_indices.first][:file]
401
- todo = NA::Todo.new(file_path: selected_file)
402
- project_names = todo.projects.map { |proj| proj.project }
403
- project_menu = project_names + ['New project']
404
- move_selector = nil
383
+ NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1) if action_obj.nil?
384
+
385
+ # If "Run Plugin" was selected, show plugin selection menu
386
+ if action_obj[:key] == :run_plugin
387
+ plugin_labels = available_plugins.map { |p| p[:label] }
388
+ plugin_selector = nil
405
389
  if TTY::Which.exist?('fzf')
406
- move_selector = 'fzf --prompt="Select project> "'
390
+ plugin_selector = 'fzf --prompt="Select plugin> "'
407
391
  elsif TTY::Which.exist?('gum')
408
- move_selector = 'gum choose'
392
+ plugin_selector = 'gum choose'
409
393
  end
410
- selected_project = nil
411
- if move_selector
394
+ selected_plugin = nil
395
+ if plugin_selector
412
396
  require 'open3'
413
- input = project_menu.join("\n")
414
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
415
- selected_project = output.strip
397
+ input = plugin_labels.join("\n")
398
+ output, = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{plugin_selector}")
399
+ selected_plugin = output.strip
416
400
  else
417
- puts 'Select a project:'
418
- project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
419
- idx = (STDIN.gets || '').strip.to_i - 1
420
- selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
401
+ puts 'Select a plugin:'
402
+ plugin_labels.each_with_index { |label, i| puts "#{i + 1}. #{label}" }
403
+ idx = ($stdin.gets || '').strip.to_i - 1
404
+ selected_plugin = plugin_labels[idx] if idx >= 0 && idx < plugin_labels.size
421
405
  end
422
- if selected_project == 'New project'
406
+ plugin_obj = available_plugins.find { |p| p[:label] == selected_plugin }
407
+ NA.notify("#{NA.theme[:error]}No plugin selected, cancelled", exit_code: 1) if plugin_obj.nil?
408
+ # Set plugin path directly
409
+ options[:plugin] = plugin_obj[:plugin_path]
410
+ elsif action_obj[:key] == :_plugin
411
+ # Legacy support: if somehow a plugin was selected directly
412
+ options[:plugin] = action_obj[:plugin_path]
413
+ else
414
+ # Prompt for parameter if needed
415
+ param_value = nil
416
+ # Only prompt for param if not :move (which has custom menu logic)
417
+ if action_obj[:param] && action_obj[:key] != :move
423
418
  if TTY::Which.exist?('gum')
424
419
  gum = TTY::Which.which('gum')
425
- prompt = 'Enter new project name: '
426
- new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
420
+ prompt = "Enter #{action_obj[:param]}: "
421
+ param_value = `#{gum} input --placeholder "#{prompt}"`.strip
427
422
  else
428
- print 'Enter new project name: '
429
- new_proj_name = (STDIN.gets || '').strip
423
+ print "Enter #{action_obj[:param]}: "
424
+ param_value = (STDIN.gets || '').strip
430
425
  end
431
- # Create the new project in the file
432
- NA.insert_project(selected_file, new_proj_name, todo.projects)
433
- options[:move] = new_proj_name
434
- else
435
- options[:move] = selected_project
436
426
  end
437
- when :restore
438
- options[:restore] = true
439
- when :archive
440
- options[:archive] = true
441
- when :note
442
- options[:note] = true
443
- note = [param_value]
427
+ # Set options for update
428
+ case action_obj[:key]
429
+ when :add_tag
430
+ options[:tag] = [param_value]
431
+ when :remove_tag
432
+ options[:remove] = [param_value]
433
+ when :delete
434
+ options[:delete] = true
435
+ when :finish
436
+ options[:finish] = true
437
+ # Timed finish? Prompt user for optional start/date inputs
438
+ if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
439
+ # Ask for start date expression
440
+ start_expr = nil
441
+ if TTY::Which.exist?('gum')
442
+ gum = TTY::Which.which('gum')
443
+ prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
444
+ start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
445
+ else
446
+ print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
447
+ start_expr = (STDIN.gets || '').strip
448
+ end
449
+ start_time = NA::Types.parse_date_begin(start_expr)
450
+ options[:started] = start_time if start_time
451
+ end
452
+ when :edit
453
+ # Just set the flag - multi-action editor will handle it below
454
+ options[:edit] = true
455
+ when :priority
456
+ options[:priority] = param_value
457
+ when :move
458
+ # Gather projects from the same file as the selected action
459
+ selected_file = targets_for_selection[selected_indices.first][:file]
460
+ todo = NA::Todo.new(file_path: selected_file)
461
+ project_names = todo.projects.map { |proj| proj.project }
462
+ project_menu = project_names + ['New project']
463
+ move_selector = nil
464
+ if TTY::Which.exist?('fzf')
465
+ move_selector = 'fzf --prompt="Select project> "'
466
+ elsif TTY::Which.exist?('gum')
467
+ move_selector = 'gum choose'
468
+ end
469
+ selected_project = nil
470
+ if move_selector
471
+ require 'open3'
472
+ input = project_menu.join("\n")
473
+ output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
474
+ selected_project = output.strip
475
+ else
476
+ puts 'Select a project:'
477
+ project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
478
+ idx = (STDIN.gets || '').strip.to_i - 1
479
+ selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
480
+ end
481
+ if selected_project == 'New project'
482
+ if TTY::Which.exist?('gum')
483
+ gum = TTY::Which.which('gum')
484
+ prompt = 'Enter new project name: '
485
+ new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
486
+ else
487
+ print 'Enter new project name: '
488
+ new_proj_name = (STDIN.gets || '').strip
489
+ end
490
+ # Create the new project in the file
491
+ NA.insert_project(selected_file, new_proj_name)
492
+ options[:move] = new_proj_name
493
+ else
494
+ options[:move] = selected_project
495
+ end
496
+ when :restore
497
+ options[:restore] = true
498
+ when :archive
499
+ options[:archive] = true
500
+ when :note
501
+ options[:note] = true
502
+ note = [param_value]
503
+ end
444
504
  end
445
505
  end
446
506
  did_direct_update = false
@@ -453,7 +513,31 @@ class App
453
513
  actions_by_file[file] << targets_for_selection[idx][:action]
454
514
  end
455
515
 
456
- # Process each file's actions
516
+ # If a plugin is specified, run it on all selected actions and apply results
517
+ if options[:plugin]
518
+ plugin_path = options[:plugin]
519
+ unless File.exist?(plugin_path)
520
+ # Resolve by name via registry
521
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
522
+ plugin_path = resolved if resolved
523
+ end
524
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
525
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
526
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
527
+ divider = (options[:divider] || '||')
528
+
529
+ all_actions = []
530
+ actions_by_file.each_value { |list| all_actions.concat(list) }
531
+ io_actions = all_actions.map(&:to_plugin_io_hash)
532
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
533
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
534
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
535
+ Array(returned).each { |h| NA.apply_plugin_result(h) }
536
+ did_direct_update = true
537
+ next
538
+ end
539
+
540
+ # Process each file's actions (non-plugin paths)
457
541
  actions_by_file.each do |file, action_list|
458
542
  # Rebuild all derived variables from options after menu-driven assignment
459
543
  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