na 1.2.88 → 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.
@@ -3,73 +3,287 @@
3
3
  class App
4
4
  extend GLI::App
5
5
 
6
- desc 'Run a plugin on selected actions'
7
- arg_name 'NAME'
6
+ desc 'Manage and run plugins'
8
7
  command %i[plugin] do |c|
9
- c.desc 'Input format (json|yaml|text)'
10
- c.arg_name 'TYPE'
11
- c.flag %i[input]
8
+ c.desc "Regenerate sample plugins and README"
9
+ c.switch %i[generate-examples]
12
10
 
13
- c.desc 'Output format (json|yaml|text)'
14
- c.arg_name 'TYPE'
15
- c.flag %i[output]
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 = '||'
16
32
 
17
- c.desc 'Text divider when using --input/--output text'
18
- c.arg_name 'STRING'
19
- c.flag %i[divider]
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] }
20
49
 
21
- c.desc 'Specify the file to search for the task'
22
- c.arg_name 'PATH'
23
- c.flag %i[file in]
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
24
60
 
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
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
28
76
 
29
- c.desc 'Filter results using search terms'
30
- c.arg_name 'QUERY'
31
- c.flag %i[search find grep], multiple: true
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
32
95
 
33
- c.desc 'Include @done actions'
34
- c.switch %i[done]
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
35
105
 
36
- c.desc 'Match actions containing tag. Allows value comparisons'
37
- c.arg_name 'TAG'
38
- c.flag %i[tagged], multiple: true
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 << ''
39
135
 
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)
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
70
162
  end
71
163
  end
72
- end
73
- end
74
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
75
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
@@ -344,14 +344,19 @@ class App
344
344
  { key: :archive, label: 'Archive', param: nil },
345
345
  { key: :note, label: 'Add Note', param: 'Note' }
346
346
  ]
347
- # Append available plugins
347
+ # Add "Run Plugin" option if there are enabled plugins with metadata
348
+ available_plugins = []
348
349
  begin
349
350
  NA::Plugins.ensure_plugins_home
350
- NA::Plugins.list_plugins.each do |_key, path|
351
+ NA::Plugins.list_plugins.each_value do |path|
351
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
+
352
356
  disp = meta['name'] || File.basename(path, File.extname(path))
353
- actions_menu << { key: :_plugin, label: "Plugin: #{disp}", param: nil, plugin_path: path }
357
+ available_plugins << { key: :_plugin, label: disp, param: nil, plugin_path: path }
354
358
  end
359
+ actions_menu << { key: :run_plugin, label: 'Run Plugin', param: nil } if available_plugins.any?
355
360
  rescue StandardError
356
361
  # ignore plugin discovery errors in menu
357
362
  end
@@ -366,110 +371,136 @@ class App
366
371
  if selector
367
372
  require 'open3'
368
373
  input = menu_labels.join("\n")
369
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
374
+ output, = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
370
375
  selected_action = output.strip
371
376
  else
372
377
  puts 'Select an action:'
373
- menu_labels.each_with_index { |label, i| puts "#{i+1}. #{label}" }
374
- 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
375
380
  selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
376
381
  end
377
382
  action_obj = actions_menu.find { |a| a[:label] == selected_action }
378
- if action_obj.nil?
379
- NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1)
380
- end
381
- # Prompt for parameter if needed
382
- param_value = nil
383
- # Only prompt for param if not :move (which has custom menu logic)
384
- if action_obj[:param] && action_obj[:key] != :move
385
- if TTY::Which.exist?('gum')
386
- gum = TTY::Which.which('gum')
387
- prompt = "Enter #{action_obj[:param]}: "
388
- param_value = `#{gum} input --placeholder "#{prompt}"`.strip
389
- else
390
- print "Enter #{action_obj[:param]}: "
391
- param_value = (STDIN.gets || '').strip
392
- end
393
- end
394
- # Set options for update
395
- case action_obj[:key]
396
- when :add_tag
397
- options[:tag] = [param_value]
398
- when :remove_tag
399
- options[:remove] = [param_value]
400
- when :delete
401
- options[:delete] = true
402
- when :finish
403
- options[:finish] = true
404
- # Timed finish? Prompt user for optional start/date inputs
405
- if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
406
- # Ask for start date expression
407
- start_expr = nil
408
- if TTY::Which.exist?('gum')
409
- gum = TTY::Which.which('gum')
410
- prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
411
- start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
412
- else
413
- print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
414
- start_expr = (STDIN.gets || '').strip
415
- end
416
- start_time = NA::Types.parse_date_begin(start_expr)
417
- options[:started] = start_time if start_time
418
- end
419
- when :edit
420
- # Just set the flag - multi-action editor will handle it below
421
- options[:edit] = true
422
- when :priority
423
- options[:priority] = param_value
424
- when :move
425
- # Gather projects from the same file as the selected action
426
- selected_file = targets_for_selection[selected_indices.first][:file]
427
- todo = NA::Todo.new(file_path: selected_file)
428
- project_names = todo.projects.map { |proj| proj.project }
429
- project_menu = project_names + ['New project']
430
- 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
431
389
  if TTY::Which.exist?('fzf')
432
- move_selector = 'fzf --prompt="Select project> "'
390
+ plugin_selector = 'fzf --prompt="Select plugin> "'
433
391
  elsif TTY::Which.exist?('gum')
434
- move_selector = 'gum choose'
392
+ plugin_selector = 'gum choose'
435
393
  end
436
- selected_project = nil
437
- if move_selector
394
+ selected_plugin = nil
395
+ if plugin_selector
438
396
  require 'open3'
439
- input = project_menu.join("\n")
440
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
441
- selected_project = output.strip
397
+ input = plugin_labels.join("\n")
398
+ output, = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{plugin_selector}")
399
+ selected_plugin = output.strip
442
400
  else
443
- puts 'Select a project:'
444
- project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
445
- idx = (STDIN.gets || '').strip.to_i - 1
446
- 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
447
405
  end
448
- 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
449
418
  if TTY::Which.exist?('gum')
450
419
  gum = TTY::Which.which('gum')
451
- prompt = 'Enter new project name: '
452
- new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
420
+ prompt = "Enter #{action_obj[:param]}: "
421
+ param_value = `#{gum} input --placeholder "#{prompt}"`.strip
453
422
  else
454
- print 'Enter new project name: '
455
- new_proj_name = (STDIN.gets || '').strip
423
+ print "Enter #{action_obj[:param]}: "
424
+ param_value = (STDIN.gets || '').strip
456
425
  end
457
- # Create the new project in the file
458
- NA.insert_project(selected_file, new_proj_name, todo.projects)
459
- options[:move] = new_proj_name
460
- else
461
- options[:move] = selected_project
462
426
  end
463
- when :restore
464
- options[:restore] = true
465
- when :archive
466
- options[:archive] = true
467
- when :note
468
- options[:note] = true
469
- note = [param_value]
470
- when :_plugin
471
- # Set plugin path directly
472
- options[:plugin] = action_obj[:plugin_path]
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
473
504
  end
474
505
  end
475
506
  did_direct_update = false
data/lib/na/action.rb CHANGED
@@ -28,6 +28,12 @@ module NA
28
28
  @note = note
29
29
  end
30
30
 
31
+ # Returns true if this action contains the current next-action tag (e.g. @na)
32
+ # @return [Boolean]
33
+ def na?
34
+ @tags.key?(NA.na_tag)
35
+ end
36
+
31
37
  # Convert action to plugin IO hash
32
38
  # @return [Hash]
33
39
  def to_plugin_io_hash
data/lib/na/actions.rb CHANGED
@@ -40,7 +40,8 @@ module NA
40
40
  filtered_actions = if config[:only_timed]
41
41
  self.select do |a|
42
42
  t = a.tags
43
- (t['started'] || t['start']) && t['done']
43
+ tl = t.transform_keys { |k| k.to_s.downcase }
44
+ (tl['started'] || tl['start']) && tl['done']
44
45
  end
45
46
  else
46
47
  self
@@ -118,7 +119,7 @@ module NA
118
119
 
119
120
  if config[:times]
120
121
  # compute duration from @started/@done
121
- tags = action.tags
122
+ tags = action.tags.transform_keys { |k| k.to_s.downcase }
122
123
  begun = tags['started'] || tags['start']
123
124
  finished = tags['done']
124
125
  if begun && finished