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.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +3 -1
- data/.rubocop_todo.yml +12 -12
- data/CHANGELOG.md +36 -0
- data/Gemfile.lock +1 -1
- data/README.md +208 -57
- data/bin/commands/plugin.rb +271 -57
- data/bin/commands/update.rb +121 -90
- data/lib/na/action.rb +6 -0
- data/lib/na/actions.rb +3 -2
- data/lib/na/next_action.rb +24 -18
- data/lib/na/plugins.rb +152 -7
- data/lib/na/version.rb +1 -1
- data/src/_README.md +142 -55
- metadata +1 -2
- data/2025-10-29-one-more-na-update.md +0 -142
data/bin/commands/plugin.rb
CHANGED
|
@@ -3,73 +3,287 @@
|
|
|
3
3
|
class App
|
|
4
4
|
extend GLI::App
|
|
5
5
|
|
|
6
|
-
desc '
|
|
7
|
-
arg_name 'NAME'
|
|
6
|
+
desc 'Manage and run plugins'
|
|
8
7
|
command %i[plugin] do |c|
|
|
9
|
-
c.desc
|
|
10
|
-
c.
|
|
11
|
-
c.flag %i[input]
|
|
8
|
+
c.desc "Regenerate sample plugins and README"
|
|
9
|
+
c.switch %i[generate-examples]
|
|
12
10
|
|
|
13
|
-
c.
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 '
|
|
26
|
-
c.arg_name '
|
|
27
|
-
c.
|
|
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 '
|
|
30
|
-
c.arg_name '
|
|
31
|
-
c.
|
|
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 '
|
|
34
|
-
c.
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
data/bin/commands/update.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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 = (
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
390
|
+
plugin_selector = 'fzf --prompt="Select plugin> "'
|
|
433
391
|
elsif TTY::Which.exist?('gum')
|
|
434
|
-
|
|
392
|
+
plugin_selector = 'gum choose'
|
|
435
393
|
end
|
|
436
|
-
|
|
437
|
-
if
|
|
394
|
+
selected_plugin = nil
|
|
395
|
+
if plugin_selector
|
|
438
396
|
require 'open3'
|
|
439
|
-
input =
|
|
440
|
-
output,
|
|
441
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
idx = (
|
|
446
|
-
|
|
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
|
-
|
|
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 =
|
|
452
|
-
|
|
420
|
+
prompt = "Enter #{action_obj[:param]}: "
|
|
421
|
+
param_value = `#{gum} input --placeholder "#{prompt}"`.strip
|
|
453
422
|
else
|
|
454
|
-
print
|
|
455
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|