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.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +3 -1
- data/.rubocop_todo.yml +24 -16
- data/CHANGELOG.md +75 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +4 -1
- data/README.md +322 -45
- data/bin/commands/find.rb +62 -0
- data/bin/commands/next.rb +65 -0
- data/bin/commands/plugin.rb +289 -0
- data/bin/commands/tagged.rb +63 -0
- data/bin/commands/update.rb +169 -85
- data/bin/na +1 -0
- data/lib/na/action.rb +19 -0
- data/lib/na/actions.rb +3 -2
- data/lib/na/next_action.rb +105 -7
- data/lib/na/plugins.rb +564 -0
- data/lib/na/string.rb +6 -4
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/na/Test.todo.markdown +32 -0
- data/na/test.md +21 -0
- data/na.gemspec +1 -0
- data/plugins.md +38 -0
- data/src/_README.md +240 -44
- metadata +20 -1
|
@@ -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
|
data/bin/commands/tagged.rb
CHANGED
|
@@ -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,
|
data/bin/commands/update.rb
CHANGED
|
@@ -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,
|
|
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 = (
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
390
|
+
plugin_selector = 'fzf --prompt="Select plugin> "'
|
|
407
391
|
elsif TTY::Which.exist?('gum')
|
|
408
|
-
|
|
392
|
+
plugin_selector = 'gum choose'
|
|
409
393
|
end
|
|
410
|
-
|
|
411
|
-
if
|
|
394
|
+
selected_plugin = nil
|
|
395
|
+
if plugin_selector
|
|
412
396
|
require 'open3'
|
|
413
|
-
input =
|
|
414
|
-
output,
|
|
415
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
idx = (
|
|
420
|
-
|
|
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
|
-
|
|
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 =
|
|
426
|
-
|
|
420
|
+
prompt = "Enter #{action_obj[:param]}: "
|
|
421
|
+
param_value = `#{gum} input --placeholder "#{prompt}"`.strip
|
|
427
422
|
else
|
|
428
|
-
print
|
|
429
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
#
|
|
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
|