na 1.2.102 → 1.2.103
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/.rubocop_todo.yml +6 -6
- data/CHANGELOG.md +23 -0
- data/Gemfile.lock +1 -1
- data/README.md +2 -2
- data/bin/commands/update.rb +330 -323
- data/lib/na/next_action.rb +8 -4
- data/lib/na/version.rb +1 -1
- data/src/_README.md +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a397b0b25b7e760071ffdfbb1ce4418f14d8049f0d52af02f653e3d278e714b1
|
|
4
|
+
data.tar.gz: fb60ff2015ab3989e529b7f857900b8171053fc33e80b5308cd922ff8825430f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a69a1f8dc42eefad043fa894a644178baa5b567d966b99f941d30f9ab3f5bf9fd753c2f1363acdb1fd5828e618d26c330fc7153c5af52c1929e2343f38b7a88a
|
|
7
|
+
data.tar.gz: 8d0dd164bae1bfdfdc1323fd7bc9d0e2f41e00ea60304f6cf7a038bd30d60cba82d6d0c254c956e55ba06c9834c8dbd591e98e45db3c46fa4e50f3d591923baf
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2026-
|
|
3
|
+
# on 2026-05-04 09:11:30 UTC using RuboCop version 1.82.1.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
@@ -40,14 +40,14 @@ Metrics/BlockNesting:
|
|
|
40
40
|
# Offense count: 6
|
|
41
41
|
# Configuration parameters: CountComments, CountAsOne.
|
|
42
42
|
Metrics/ClassLength:
|
|
43
|
-
Max:
|
|
43
|
+
Max: 1503
|
|
44
44
|
|
|
45
|
-
# Offense count:
|
|
45
|
+
# Offense count: 42
|
|
46
46
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
47
47
|
Metrics/CyclomaticComplexity:
|
|
48
48
|
Max: 97
|
|
49
49
|
|
|
50
|
-
# Offense count:
|
|
50
|
+
# Offense count: 64
|
|
51
51
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
|
|
52
52
|
Metrics/MethodLength:
|
|
53
53
|
Max: 239
|
|
@@ -55,7 +55,7 @@ Metrics/MethodLength:
|
|
|
55
55
|
# Offense count: 5
|
|
56
56
|
# Configuration parameters: CountComments, CountAsOne.
|
|
57
57
|
Metrics/ModuleLength:
|
|
58
|
-
Max:
|
|
58
|
+
Max: 1505
|
|
59
59
|
|
|
60
60
|
# Offense count: 5
|
|
61
61
|
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
|
@@ -142,7 +142,7 @@ Style/YAMLFileRead:
|
|
|
142
142
|
|
|
143
143
|
# Offense count: 40
|
|
144
144
|
# This cop supports safe autocorrection (--autocorrect).
|
|
145
|
-
# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes,
|
|
145
|
+
# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
|
|
146
146
|
# URISchemes: http, https
|
|
147
147
|
Layout/LineLength:
|
|
148
148
|
Max: 293
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
### 1.2.103
|
|
2
|
+
|
|
3
|
+
2026-05-04 04:11
|
|
4
|
+
|
|
5
|
+
#### CHANGED
|
|
6
|
+
|
|
7
|
+
- Allow next --available with filters to match actions without the default @na tag.
|
|
8
|
+
- Bump the gem version to 1.2.101.
|
|
9
|
+
- Bump the gem version to 1.2.102.
|
|
10
|
+
- Bump the locked gem version to 1.2.103.
|
|
11
|
+
|
|
12
|
+
#### NEW
|
|
13
|
+
|
|
14
|
+
- Add next --available/-a to show the first available action per project.
|
|
15
|
+
|
|
16
|
+
#### FIXED
|
|
17
|
+
|
|
18
|
+
- Match unique nested projects by leaf name in add/update
|
|
19
|
+
- Make --color force colored output even when stdout is not a TTY.
|
|
20
|
+
- Make -f FILE --color force colored output when stdout is not a TTY.
|
|
21
|
+
- Make update PATH:LINE use 1-based editor line numbers and update only the requested action.
|
|
22
|
+
- Let update PATH:LINE run without search terms and skip fzf/gum selection prompts.
|
|
23
|
+
|
|
1
24
|
### 1.2.102
|
|
2
25
|
|
|
3
26
|
2026-04-27 04:36
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
_If you're one of the rare people like me who find this useful, feel free to
|
|
10
10
|
[buy me some coffee][donate]._
|
|
11
11
|
|
|
12
|
-
The current version of `na` is 1.2.
|
|
12
|
+
The current version of `na` is 1.2.103.
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
### Table of contents
|
|
@@ -257,7 +257,7 @@ SYNOPSIS
|
|
|
257
257
|
na [global options] command [command options] [arguments...]
|
|
258
258
|
|
|
259
259
|
VERSION
|
|
260
|
-
1.2.
|
|
260
|
+
1.2.103
|
|
261
261
|
|
|
262
262
|
GLOBAL OPTIONS
|
|
263
263
|
-a, --add - Add a next action (deprecated, for backwards compatibility)
|
data/bin/commands/update.rb
CHANGED
|
@@ -202,7 +202,12 @@ class App
|
|
|
202
202
|
# Verify file exists
|
|
203
203
|
if File.exist?(target_file)
|
|
204
204
|
options[:file] = target_file
|
|
205
|
-
|
|
205
|
+
# PATH:LINE uses 1-based editor line numbers; find_actions matches Action#line (0-based index).
|
|
206
|
+
user_line = target_line
|
|
207
|
+
if user_line < 1
|
|
208
|
+
NA.notify("#{NA.theme[:error]}Invalid line number (use 1-based line like file.taskpaper:3)", exit_code: 1)
|
|
209
|
+
end
|
|
210
|
+
options[:target_line] = user_line - 1
|
|
206
211
|
action = nil # Skip search processing
|
|
207
212
|
else
|
|
208
213
|
NA.notify("#{NA.theme[:error]}File not found: #{target_file}", exit_code: 1)
|
|
@@ -240,366 +245,368 @@ class App
|
|
|
240
245
|
tokens = nil # No search, list all
|
|
241
246
|
end
|
|
242
247
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
regex: options[:regex],
|
|
251
|
-
require_na: false,
|
|
252
|
-
search: tokens,
|
|
253
|
-
tag: tags
|
|
254
|
-
})
|
|
255
|
-
files.each do |file|
|
|
256
|
-
safe_search = (tokens.is_a?(String) || tokens.is_a?(Array) || tokens.is_a?(Regexp)) ? tokens : nil
|
|
257
|
-
todo = NA::Todo.new({
|
|
258
|
-
search: safe_search,
|
|
259
|
-
search_note: options[:search_notes],
|
|
260
|
-
require_na: false,
|
|
261
|
-
file_path: file,
|
|
248
|
+
unless options[:target_line] && options[:file]
|
|
249
|
+
# Gather all candidate actions for selection
|
|
250
|
+
candidate_actions = []
|
|
251
|
+
targets_for_selection = []
|
|
252
|
+
files = NA.find_files_matching({
|
|
253
|
+
depth: options[:depth],
|
|
254
|
+
done: options[:done],
|
|
262
255
|
project: options[:project],
|
|
263
|
-
|
|
264
|
-
|
|
256
|
+
regex: options[:regex],
|
|
257
|
+
require_na: false,
|
|
258
|
+
search: tokens,
|
|
259
|
+
tag: tags
|
|
265
260
|
})
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
261
|
+
files.each do |file|
|
|
262
|
+
safe_search = (tokens.is_a?(String) || tokens.is_a?(Array) || tokens.is_a?(Regexp)) ? tokens : nil
|
|
263
|
+
todo = NA::Todo.new({
|
|
264
|
+
search: safe_search,
|
|
265
|
+
search_note: options[:search_notes],
|
|
266
|
+
require_na: false,
|
|
267
|
+
file_path: file,
|
|
268
|
+
project: options[:project],
|
|
269
|
+
tag: tags,
|
|
270
|
+
done: options[:done]
|
|
271
|
+
})
|
|
272
|
+
todo.actions.each do |action_obj|
|
|
273
|
+
# Format: filename:LINENUM:parent > action
|
|
274
|
+
# Include line number in display for unique matching
|
|
275
|
+
display = "#{File.basename(action_obj.file_path)}:#{action_obj.file_line}:#{action_obj.parent.join('>')} | #{action_obj.action}"
|
|
276
|
+
candidate_actions << display
|
|
277
|
+
targets_for_selection << { file: action_obj.file_path, line: action_obj.file_line, action: action_obj }
|
|
278
|
+
end
|
|
272
279
|
end
|
|
273
|
-
end
|
|
274
280
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
# Multi-select using fzf or gum if available
|
|
282
|
+
selected_indices = []
|
|
283
|
+
if candidate_actions.any?
|
|
284
|
+
selector = nil
|
|
285
|
+
if TTY::Which.exist?('fzf')
|
|
286
|
+
selector = 'fzf --multi --prompt="Select tasks> "'
|
|
287
|
+
elsif TTY::Which.exist?('gum')
|
|
288
|
+
selector = 'gum choose --no-limit'
|
|
289
|
+
end
|
|
290
|
+
if selector
|
|
291
|
+
require 'open3'
|
|
292
|
+
input = candidate_actions.join("\n")
|
|
287
293
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
# Use popen3 to properly handle stdin for fzf
|
|
295
|
+
Open3.popen3(selector) do |stdin, stdout, stderr, wait_thr|
|
|
296
|
+
stdin.write(input)
|
|
297
|
+
stdin.close
|
|
292
298
|
|
|
293
|
-
|
|
299
|
+
output = stdout.read
|
|
294
300
|
|
|
295
|
-
|
|
301
|
+
selected = output.split("\n").map(&:strip).reject(&:empty?)
|
|
296
302
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
303
|
+
# Track which candidates have been matched to avoid duplicates
|
|
304
|
+
selected_indices = []
|
|
305
|
+
candidate_actions.each_index do |i|
|
|
306
|
+
if selected.include?(candidate_actions[i])
|
|
307
|
+
selected_indices << i unless selected_indices.include?(i)
|
|
308
|
+
end
|
|
302
309
|
end
|
|
303
310
|
end
|
|
311
|
+
else
|
|
312
|
+
# Fallback: select all or prompt for search string
|
|
313
|
+
selected_indices = (0...candidate_actions.size).to_a
|
|
304
314
|
end
|
|
305
|
-
else
|
|
306
|
-
# Fallback: select all or prompt for search string
|
|
307
|
-
selected_indices = (0...candidate_actions.size).to_a
|
|
308
315
|
end
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# If no actions found, notify and exit
|
|
312
|
-
if selected_indices.empty?
|
|
313
|
-
NA.notify("#{NA.theme[:error]}No matching actions found for selection", exit_code: 1)
|
|
314
|
-
end
|
|
315
316
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
(options[:priority].to_i if options[:priority]).to_i.positive?,
|
|
320
|
-
!options[:move].to_s.empty?,
|
|
321
|
-
!(options[:tag].nil? || options[:tag].empty?),
|
|
322
|
-
!(options[:remove].nil? || options[:remove].empty?),
|
|
323
|
-
!options[:replace].to_s.empty?,
|
|
324
|
-
options[:finish],
|
|
325
|
-
options[:archive],
|
|
326
|
-
options[:restore],
|
|
327
|
-
options[:delete],
|
|
328
|
-
options[:edit],
|
|
329
|
-
options[:started],
|
|
330
|
-
(options[:end] || options[:finished]),
|
|
331
|
-
options[:duration],
|
|
332
|
-
!options[:plugin].to_s.empty?
|
|
333
|
-
].any?
|
|
334
|
-
unless actionable
|
|
335
|
-
# Interactive menu for actions
|
|
336
|
-
actions_menu = [
|
|
337
|
-
{ key: :add_tag, label: 'Add Tag', param: 'Tag' },
|
|
338
|
-
{ key: :remove_tag, label: 'Remove Tag', param: 'Tag' },
|
|
339
|
-
{ key: :delete, label: 'Delete', param: nil },
|
|
340
|
-
{ key: :finish, label: 'Finish (mark done)', param: nil },
|
|
341
|
-
{ key: :edit, label: 'Edit', param: nil },
|
|
342
|
-
{ key: :priority, label: 'Set Priority', param: 'Priority (1-5)' },
|
|
343
|
-
{ key: :move, label: 'Move to Project', param: 'Project' },
|
|
344
|
-
{ key: :restore, label: 'Restore', param: nil },
|
|
345
|
-
{ key: :archive, label: 'Archive', param: nil },
|
|
346
|
-
{ key: :note, label: 'Add Note', param: 'Note' }
|
|
347
|
-
]
|
|
348
|
-
# Add plugin options directly to the actions menu if there are enabled plugins with metadata
|
|
349
|
-
begin
|
|
350
|
-
available_plugins = []
|
|
351
|
-
NA::Plugins.ensure_plugins_home
|
|
352
|
-
NA::Plugins.list_plugins.each_value do |path|
|
|
353
|
-
meta = NA::Plugins.parse_plugin_metadata(path)
|
|
354
|
-
# Only include plugins with both input and output metadata
|
|
355
|
-
next unless meta['input'] && meta['output']
|
|
356
|
-
|
|
357
|
-
disp = meta['name'] || File.basename(path, File.extname(path))
|
|
358
|
-
available_plugins << { label: disp, plugin_path: path }
|
|
359
|
-
end
|
|
360
|
-
available_plugins.each do |plugin|
|
|
361
|
-
actions_menu << {
|
|
362
|
-
key: :_plugin,
|
|
363
|
-
label: "Plugin: #{plugin[:label]}",
|
|
364
|
-
param: nil,
|
|
365
|
-
plugin_path: plugin[:plugin_path]
|
|
366
|
-
}
|
|
367
|
-
end
|
|
368
|
-
rescue StandardError
|
|
369
|
-
# ignore plugin discovery errors in menu
|
|
370
|
-
end
|
|
371
|
-
selector = nil
|
|
372
|
-
if TTY::Which.exist?('fzf')
|
|
373
|
-
selector = 'fzf --prompt="Select action> "'
|
|
374
|
-
elsif TTY::Which.exist?('gum')
|
|
375
|
-
selector = 'gum choose'
|
|
376
|
-
end
|
|
377
|
-
menu_labels = actions_menu.map { |a| a[:label] }
|
|
378
|
-
selected_action = nil
|
|
379
|
-
if selector
|
|
380
|
-
require 'open3'
|
|
381
|
-
input = menu_labels.join("\n")
|
|
382
|
-
output, = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
|
|
383
|
-
selected_action = output.strip
|
|
384
|
-
else
|
|
385
|
-
puts 'Select an action:'
|
|
386
|
-
menu_labels.each_with_index { |label, i| puts "#{i + 1}. #{label}" }
|
|
387
|
-
idx = ($stdin.gets || '').strip.to_i - 1
|
|
388
|
-
selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
|
|
317
|
+
# If no actions found, notify and exit
|
|
318
|
+
if selected_indices.empty?
|
|
319
|
+
NA.notify("#{NA.theme[:error]}No matching actions found for selection", exit_code: 1)
|
|
389
320
|
end
|
|
390
|
-
action_obj = actions_menu.find { |a| a[:label] == selected_action }
|
|
391
|
-
NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1) if action_obj.nil?
|
|
392
321
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
options[:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
322
|
+
# Apply update to selected actions
|
|
323
|
+
actionable = [
|
|
324
|
+
options[:note],
|
|
325
|
+
(options[:priority].to_i if options[:priority]).to_i.positive?,
|
|
326
|
+
!options[:move].to_s.empty?,
|
|
327
|
+
!(options[:tag].nil? || options[:tag].empty?),
|
|
328
|
+
!(options[:remove].nil? || options[:remove].empty?),
|
|
329
|
+
!options[:replace].to_s.empty?,
|
|
330
|
+
options[:finish],
|
|
331
|
+
options[:archive],
|
|
332
|
+
options[:restore],
|
|
333
|
+
options[:delete],
|
|
334
|
+
options[:edit],
|
|
335
|
+
options[:started],
|
|
336
|
+
(options[:end] || options[:finished]),
|
|
337
|
+
options[:duration],
|
|
338
|
+
!options[:plugin].to_s.empty?
|
|
339
|
+
].any?
|
|
340
|
+
unless actionable
|
|
341
|
+
# Interactive menu for actions
|
|
342
|
+
actions_menu = [
|
|
343
|
+
{ key: :add_tag, label: 'Add Tag', param: 'Tag' },
|
|
344
|
+
{ key: :remove_tag, label: 'Remove Tag', param: 'Tag' },
|
|
345
|
+
{ key: :delete, label: 'Delete', param: nil },
|
|
346
|
+
{ key: :finish, label: 'Finish (mark done)', param: nil },
|
|
347
|
+
{ key: :edit, label: 'Edit', param: nil },
|
|
348
|
+
{ key: :priority, label: 'Set Priority', param: 'Priority (1-5)' },
|
|
349
|
+
{ key: :move, label: 'Move to Project', param: 'Project' },
|
|
350
|
+
{ key: :restore, label: 'Restore', param: nil },
|
|
351
|
+
{ key: :archive, label: 'Archive', param: nil },
|
|
352
|
+
{ key: :note, label: 'Add Note', param: 'Note' }
|
|
353
|
+
]
|
|
354
|
+
# Add plugin options directly to the actions menu if there are enabled plugins with metadata
|
|
355
|
+
begin
|
|
356
|
+
available_plugins = []
|
|
357
|
+
NA::Plugins.ensure_plugins_home
|
|
358
|
+
NA::Plugins.list_plugins.each_value do |path|
|
|
359
|
+
meta = NA::Plugins.parse_plugin_metadata(path)
|
|
360
|
+
# Only include plugins with both input and output metadata
|
|
361
|
+
next unless meta['input'] && meta['output']
|
|
362
|
+
|
|
363
|
+
disp = meta['name'] || File.basename(path, File.extname(path))
|
|
364
|
+
available_plugins << { label: disp, plugin_path: path }
|
|
365
|
+
end
|
|
366
|
+
available_plugins.each do |plugin|
|
|
367
|
+
actions_menu << {
|
|
368
|
+
key: :_plugin,
|
|
369
|
+
label: "Plugin: #{plugin[:label]}",
|
|
370
|
+
param: nil,
|
|
371
|
+
plugin_path: plugin[:plugin_path]
|
|
372
|
+
}
|
|
408
373
|
end
|
|
374
|
+
rescue StandardError
|
|
375
|
+
# ignore plugin discovery errors in menu
|
|
376
|
+
end
|
|
377
|
+
selector = nil
|
|
378
|
+
if TTY::Which.exist?('fzf')
|
|
379
|
+
selector = 'fzf --prompt="Select action> "'
|
|
380
|
+
elsif TTY::Which.exist?('gum')
|
|
381
|
+
selector = 'gum choose'
|
|
382
|
+
end
|
|
383
|
+
menu_labels = actions_menu.map { |a| a[:label] }
|
|
384
|
+
selected_action = nil
|
|
385
|
+
if selector
|
|
386
|
+
require 'open3'
|
|
387
|
+
input = menu_labels.join("\n")
|
|
388
|
+
output, = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
|
|
389
|
+
selected_action = output.strip
|
|
390
|
+
else
|
|
391
|
+
puts 'Select an action:'
|
|
392
|
+
menu_labels.each_with_index { |label, i| puts "#{i + 1}. #{label}" }
|
|
393
|
+
idx = ($stdin.gets || '').strip.to_i - 1
|
|
394
|
+
selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
|
|
409
395
|
end
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
options[:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
|
|
422
|
-
# Ask for start date expression
|
|
423
|
-
start_expr = nil
|
|
396
|
+
action_obj = actions_menu.find { |a| a[:label] == selected_action }
|
|
397
|
+
NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1) if action_obj.nil?
|
|
398
|
+
|
|
399
|
+
if action_obj[:key] == :_plugin
|
|
400
|
+
# Plugin selected directly from the main actions menu
|
|
401
|
+
options[:plugin] = action_obj[:plugin_path]
|
|
402
|
+
else
|
|
403
|
+
# Prompt for parameter if needed
|
|
404
|
+
param_value = nil
|
|
405
|
+
# Only prompt for param if not :move (which has custom menu logic)
|
|
406
|
+
if action_obj[:param] && action_obj[:key] != :move
|
|
424
407
|
if TTY::Which.exist?('gum')
|
|
425
408
|
gum = TTY::Which.which('gum')
|
|
426
|
-
prompt =
|
|
427
|
-
|
|
409
|
+
prompt = "Enter #{action_obj[:param]}: "
|
|
410
|
+
param_value = `#{gum} input --placeholder "#{prompt}"`.strip
|
|
428
411
|
else
|
|
429
|
-
print
|
|
430
|
-
|
|
412
|
+
print "Enter #{action_obj[:param]}: "
|
|
413
|
+
param_value = (STDIN.gets || '').strip
|
|
431
414
|
end
|
|
432
|
-
start_time = NA::Types.parse_date_begin(start_expr)
|
|
433
|
-
options[:started] = start_time if start_time
|
|
434
|
-
end
|
|
435
|
-
when :edit
|
|
436
|
-
# Just set the flag - multi-action editor will handle it below
|
|
437
|
-
options[:edit] = true
|
|
438
|
-
when :priority
|
|
439
|
-
options[:priority] = param_value
|
|
440
|
-
when :move
|
|
441
|
-
# Gather projects from the same file as the selected action
|
|
442
|
-
selected_file = targets_for_selection[selected_indices.first][:file]
|
|
443
|
-
todo = NA::Todo.new(file_path: selected_file)
|
|
444
|
-
project_names = todo.projects.map { |proj| proj.project }
|
|
445
|
-
project_menu = project_names + ['New project']
|
|
446
|
-
move_selector = nil
|
|
447
|
-
if TTY::Which.exist?('fzf')
|
|
448
|
-
move_selector = 'fzf --prompt="Select project> "'
|
|
449
|
-
elsif TTY::Which.exist?('gum')
|
|
450
|
-
move_selector = 'gum choose'
|
|
451
415
|
end
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
416
|
+
# Set options for update
|
|
417
|
+
case action_obj[:key]
|
|
418
|
+
when :add_tag
|
|
419
|
+
options[:tag] = [param_value]
|
|
420
|
+
when :remove_tag
|
|
421
|
+
options[:remove] = [param_value]
|
|
422
|
+
when :delete
|
|
423
|
+
options[:delete] = true
|
|
424
|
+
when :finish
|
|
425
|
+
options[:finish] = true
|
|
426
|
+
# Timed finish? Prompt user for optional start/date inputs
|
|
427
|
+
if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
|
|
428
|
+
# Ask for start date expression
|
|
429
|
+
start_expr = nil
|
|
430
|
+
if TTY::Which.exist?('gum')
|
|
431
|
+
gum = TTY::Which.which('gum')
|
|
432
|
+
prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
|
|
433
|
+
start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
|
|
434
|
+
else
|
|
435
|
+
print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
|
|
436
|
+
start_expr = (STDIN.gets || '').strip
|
|
437
|
+
end
|
|
438
|
+
start_time = NA::Types.parse_date_begin(start_expr)
|
|
439
|
+
options[:started] = start_time if start_time
|
|
440
|
+
end
|
|
441
|
+
when :edit
|
|
442
|
+
# Just set the flag - multi-action editor will handle it below
|
|
443
|
+
options[:edit] = true
|
|
444
|
+
when :priority
|
|
445
|
+
options[:priority] = param_value
|
|
446
|
+
when :move
|
|
447
|
+
# Gather projects from the same file as the selected action
|
|
448
|
+
selected_file = targets_for_selection[selected_indices.first][:file]
|
|
449
|
+
todo = NA::Todo.new(file_path: selected_file)
|
|
450
|
+
project_names = todo.projects.map { |proj| proj.project }
|
|
451
|
+
project_menu = project_names + ['New project']
|
|
452
|
+
move_selector = nil
|
|
453
|
+
if TTY::Which.exist?('fzf')
|
|
454
|
+
move_selector = 'fzf --prompt="Select project> "'
|
|
455
|
+
elsif TTY::Which.exist?('gum')
|
|
456
|
+
move_selector = 'gum choose'
|
|
457
|
+
end
|
|
458
|
+
selected_project = nil
|
|
459
|
+
if move_selector
|
|
460
|
+
require 'open3'
|
|
461
|
+
input = project_menu.join("\n")
|
|
462
|
+
output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
|
|
463
|
+
selected_project = output.strip
|
|
464
|
+
else
|
|
465
|
+
puts 'Select a project:'
|
|
466
|
+
project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
|
|
467
|
+
idx = (STDIN.gets || '').strip.to_i - 1
|
|
468
|
+
selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
|
|
469
|
+
end
|
|
470
|
+
if selected_project == 'New project'
|
|
471
|
+
if TTY::Which.exist?('gum')
|
|
472
|
+
gum = TTY::Which.which('gum')
|
|
473
|
+
prompt = 'Enter new project name: '
|
|
474
|
+
new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
|
|
475
|
+
else
|
|
476
|
+
print 'Enter new project name: '
|
|
477
|
+
new_proj_name = (STDIN.gets || '').strip
|
|
478
|
+
end
|
|
479
|
+
# Create the new project in the file
|
|
480
|
+
NA.insert_project(selected_file, new_proj_name)
|
|
481
|
+
options[:move] = new_proj_name
|
|
469
482
|
else
|
|
470
|
-
|
|
471
|
-
new_proj_name = (STDIN.gets || '').strip
|
|
483
|
+
options[:move] = selected_project
|
|
472
484
|
end
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
485
|
+
when :restore
|
|
486
|
+
options[:restore] = true
|
|
487
|
+
when :archive
|
|
488
|
+
options[:archive] = true
|
|
489
|
+
when :note
|
|
490
|
+
options[:note] = true
|
|
491
|
+
note = [param_value]
|
|
478
492
|
end
|
|
479
|
-
when :restore
|
|
480
|
-
options[:restore] = true
|
|
481
|
-
when :archive
|
|
482
|
-
options[:archive] = true
|
|
483
|
-
when :note
|
|
484
|
-
options[:note] = true
|
|
485
|
-
note = [param_value]
|
|
486
493
|
end
|
|
487
494
|
end
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
actions_by_file[file] << targets_for_selection[idx][:action]
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
# If a plugin is specified, run it on all selected actions and apply results
|
|
500
|
-
if options[:plugin]
|
|
501
|
-
plugin_path = options[:plugin]
|
|
502
|
-
unless File.exist?(plugin_path)
|
|
503
|
-
# Resolve by name via registry
|
|
504
|
-
resolved = NA::Plugins.resolve_plugin(plugin_path)
|
|
505
|
-
plugin_path = resolved if resolved
|
|
495
|
+
did_direct_update = false
|
|
496
|
+
|
|
497
|
+
# Group selected actions by file for batch processing
|
|
498
|
+
actions_by_file = {}
|
|
499
|
+
selected_indices.each do |idx|
|
|
500
|
+
file = targets_for_selection[idx][:file]
|
|
501
|
+
actions_by_file[file] ||= []
|
|
502
|
+
actions_by_file[file] << targets_for_selection[idx][:action]
|
|
506
503
|
end
|
|
507
|
-
meta = NA::Plugins.parse_plugin_metadata(plugin_path)
|
|
508
|
-
input_fmt = (options[:input] || meta['input'] || 'json').to_s
|
|
509
|
-
output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
|
|
510
|
-
divider = (options[:divider] || '||')
|
|
511
|
-
|
|
512
|
-
all_actions = []
|
|
513
|
-
actions_by_file.each_value { |list| all_actions.concat(list) }
|
|
514
|
-
io_actions = all_actions.map(&:to_plugin_io_hash)
|
|
515
|
-
stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
|
|
516
|
-
stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
|
|
517
|
-
returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
|
|
518
|
-
Array(returned).each { |h| NA.apply_plugin_result(h) }
|
|
519
|
-
did_direct_update = true
|
|
520
|
-
next
|
|
521
|
-
end
|
|
522
504
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
505
|
+
# If a plugin is specified, run it on all selected actions and apply results
|
|
506
|
+
if options[:plugin]
|
|
507
|
+
plugin_path = options[:plugin]
|
|
508
|
+
unless File.exist?(plugin_path)
|
|
509
|
+
# Resolve by name via registry
|
|
510
|
+
resolved = NA::Plugins.resolve_plugin(plugin_path)
|
|
511
|
+
plugin_path = resolved if resolved
|
|
512
|
+
end
|
|
513
|
+
meta = NA::Plugins.parse_plugin_metadata(plugin_path)
|
|
514
|
+
input_fmt = (options[:input] || meta['input'] || 'json').to_s
|
|
515
|
+
output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
|
|
516
|
+
divider = (options[:divider] || '||')
|
|
517
|
+
|
|
518
|
+
all_actions = []
|
|
519
|
+
actions_by_file.each_value { |list| all_actions.concat(list) }
|
|
520
|
+
io_actions = all_actions.map(&:to_plugin_io_hash)
|
|
521
|
+
stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
|
|
522
|
+
stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
|
|
523
|
+
returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
|
|
524
|
+
Array(returned).each { |h| NA.apply_plugin_result(h) }
|
|
525
|
+
did_direct_update = true
|
|
526
|
+
next
|
|
538
527
|
end
|
|
539
528
|
|
|
540
|
-
#
|
|
541
|
-
|
|
542
|
-
#
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
529
|
+
# Process each file's actions (non-plugin paths)
|
|
530
|
+
actions_by_file.each do |file, action_list|
|
|
531
|
+
# Rebuild all derived variables from options after menu-driven assignment
|
|
532
|
+
add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
|
|
533
|
+
remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
|
|
534
|
+
remove_tags << 'done' if options[:restore]
|
|
535
|
+
priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
|
|
536
|
+
target_proj = if options[:move]
|
|
537
|
+
options[:move]
|
|
538
|
+
elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
|
|
539
|
+
NA.cwd
|
|
540
|
+
end
|
|
541
|
+
note_val = note
|
|
542
|
+
if options[:note] && defined?(param_value) && param_value
|
|
543
|
+
note_val = [param_value]
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Handle edit with multiple actions
|
|
547
|
+
if options[:edit]
|
|
548
|
+
# Open editor once with all actions for this file
|
|
549
|
+
editor_content = NA::Editor.format_multi_action_input(action_list)
|
|
550
|
+
edited_content = NA::Editor.fork_editor(editor_content)
|
|
551
|
+
edited_actions = NA::Editor.parse_multi_action_output(edited_content)
|
|
552
|
+
|
|
553
|
+
# If markers were removed but we have the same number of actions, match by position
|
|
554
|
+
if edited_actions.empty? && action_list.size > 0
|
|
555
|
+
# Parse content line by line, skipping comments and blanks
|
|
556
|
+
non_comment_lines = edited_content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
|
|
557
|
+
|
|
558
|
+
# Match each non-comment line to an action by position
|
|
559
|
+
action_list.each_with_index do |action_obj, idx|
|
|
560
|
+
if non_comment_lines[idx]
|
|
561
|
+
# Split into action and notes
|
|
562
|
+
lines = non_comment_lines[idx..-1]
|
|
563
|
+
action_text = lines[0]
|
|
564
|
+
note_lines = lines[1..-1] || []
|
|
565
|
+
|
|
566
|
+
# Store by file:line key
|
|
567
|
+
key = "#{action_obj.file_path}:#{action_obj.file_line}"
|
|
568
|
+
edited_actions[key] = [action_text, note_lines]
|
|
569
|
+
end
|
|
563
570
|
end
|
|
564
571
|
end
|
|
565
|
-
end
|
|
566
572
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
573
|
+
# Update each action with edited content
|
|
574
|
+
action_list.each do |action_obj|
|
|
575
|
+
key = "#{action_obj.file_path}:#{action_obj.file_line}"
|
|
576
|
+
if edited_actions[key]
|
|
577
|
+
action_obj.action, action_obj.note = edited_actions[key]
|
|
578
|
+
end
|
|
572
579
|
end
|
|
573
580
|
end
|
|
574
|
-
end
|
|
575
581
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
582
|
+
# Update each action (process from bottom to top to avoid line shifts)
|
|
583
|
+
action_list.sort_by(&:file_line).reverse.each do |action_obj|
|
|
584
|
+
NA.update_action(file, nil,
|
|
585
|
+
add: action_obj,
|
|
586
|
+
add_tag: add_tags,
|
|
587
|
+
all: true,
|
|
588
|
+
append: append,
|
|
589
|
+
delete: options[:delete],
|
|
590
|
+
done: options[:done],
|
|
591
|
+
edit: false, # Already handled above
|
|
592
|
+
finish: options[:finish],
|
|
593
|
+
move: target_proj,
|
|
594
|
+
note: note_val,
|
|
595
|
+
overwrite: options[:overwrite],
|
|
596
|
+
priority: priority,
|
|
597
|
+
project: options[:project],
|
|
598
|
+
remove_tag: remove_tags,
|
|
599
|
+
replace: options[:replace],
|
|
600
|
+
search_note: options[:search_notes],
|
|
601
|
+
tagged: nil)
|
|
602
|
+
end
|
|
603
|
+
did_direct_update = true
|
|
604
|
+
end
|
|
605
|
+
if did_direct_update
|
|
606
|
+
next
|
|
596
607
|
end
|
|
597
|
-
did_direct_update = true
|
|
598
|
-
end
|
|
599
|
-
if did_direct_update
|
|
600
|
-
next
|
|
601
|
-
end
|
|
602
608
|
|
|
609
|
+
end
|
|
603
610
|
all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
|
|
604
611
|
tags = []
|
|
605
612
|
options[:tagged].join(',').split(/ *, */).each do |arg|
|
|
@@ -709,7 +716,7 @@ class App
|
|
|
709
716
|
options[:move] = 'Archive'
|
|
710
717
|
end
|
|
711
718
|
|
|
712
|
-
NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
|
|
719
|
+
NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty? && !options[:target_line]
|
|
713
720
|
|
|
714
721
|
# Handle target_line if provided (from PATH:LINE format)
|
|
715
722
|
search_tokens = if options[:target_line]
|
data/lib/na/next_action.rb
CHANGED
|
@@ -264,7 +264,9 @@ module NA
|
|
|
264
264
|
# @param target_line [Integer] Specific line number to target
|
|
265
265
|
# @return [Array] Projects and actions
|
|
266
266
|
def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil)
|
|
267
|
-
|
|
267
|
+
# PATH:LINE passes search as { target_line: n }; Todo expects string/array/regexp for :search.
|
|
268
|
+
search_for_todo = target_line ? nil : search
|
|
269
|
+
todo = NA::Todo.new({ search: search_for_todo,
|
|
268
270
|
search_note: search_note,
|
|
269
271
|
require_na: false,
|
|
270
272
|
file_path: target,
|
|
@@ -278,9 +280,9 @@ module NA
|
|
|
278
280
|
return [todo.projects, NA::Actions.new]
|
|
279
281
|
end
|
|
280
282
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
#
|
|
283
|
+
# If target_line is specified, find the action at that specific line (must run before
|
|
284
|
+
# the `all` shortcut below, otherwise --all would return every action and skip this filter).
|
|
285
|
+
# target_line is Action#line (0-based file line index). PATH:LINE on the CLI converts to 0-based in update.rb.
|
|
284
286
|
if target_line
|
|
285
287
|
matching_action = todo.actions.find { |a| a.line == target_line }
|
|
286
288
|
return [todo.projects, NA::Actions.new([matching_action])] if matching_action
|
|
@@ -290,6 +292,8 @@ module NA
|
|
|
290
292
|
|
|
291
293
|
end
|
|
292
294
|
|
|
295
|
+
return [todo.projects, todo.actions] if todo.actions.one? || all
|
|
296
|
+
|
|
293
297
|
options = todo.actions.map { |action| "#{action.file} : #{action.action}" }
|
|
294
298
|
res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
|
|
295
299
|
|
data/lib/na/version.rb
CHANGED
data/src/_README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
_If you're one of the rare people like me who find this useful, feel free to
|
|
10
10
|
[buy me some coffee][donate]._
|
|
11
11
|
|
|
12
|
-
The current version of `na` is <!--VER-->1.2.
|
|
12
|
+
The current version of `na` is <!--VER-->1.2.102<!--END VER-->.
|
|
13
13
|
|
|
14
14
|
<!--GITHUB-->
|
|
15
15
|
### Table of contents
|