na 1.2.101 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1cf2028aa8b5420b1943873ccd9defc0cea44ba83ed76d437d33fd2676d753d
4
- data.tar.gz: a33b579fa622f70a4b3771dc86a63d324be2fd751aa87980c02ad1a6f1556221
3
+ metadata.gz: a397b0b25b7e760071ffdfbb1ce4418f14d8049f0d52af02f653e3d278e714b1
4
+ data.tar.gz: fb60ff2015ab3989e529b7f857900b8171053fc33e80b5308cd922ff8825430f
5
5
  SHA512:
6
- metadata.gz: b14815f0edac0f8d48ba15d0005e2c57b10e1db28e6fba687374d86b17cfd087eed8476a5e816d5ffb425009bfd20ee2b852a0fc5be5430efecc81d34843ae86
7
- data.tar.gz: ea4ef1b69c4bac50a371362439de89b3721d07b42533cc1bcff9e377ead25b8034a25f2ab9c98e1889c6ea5beec636670d469a9388b4178ea5b79296942c6790
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-02-27 15:51:35 UTC using RuboCop version 1.81.7.
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: 1502
43
+ Max: 1503
44
44
 
45
- # Offense count: 41
45
+ # Offense count: 42
46
46
  # Configuration parameters: AllowedMethods, AllowedPatterns.
47
47
  Metrics/CyclomaticComplexity:
48
48
  Max: 97
49
49
 
50
- # Offense count: 63
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: 1504
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, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
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,46 @@
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
+
24
+ ### 1.2.102
25
+
26
+ 2026-04-27 04:36
27
+
28
+ #### CHANGED
29
+
30
+ - Allow next --available with filters to match actions without the default @na tag.
31
+ - Bump the gem version to 1.2.101.
32
+ - Bump the gem version to 1.2.102.
33
+
34
+ #### NEW
35
+
36
+ - Add next --available/-a to show the first available action per project.
37
+
38
+ #### FIXED
39
+
40
+ - Match unique nested projects by leaf name in add/update
41
+ - Make --color force colored output even when stdout is not a TTY.
42
+ - Make -f FILE --color force colored output when stdout is not a TTY.
43
+
1
44
  ### 1.2.101
2
45
 
3
46
  2026-04-26 12:06
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- na (1.2.101)
4
+ na (1.2.103)
5
5
  chronic (~> 0.10, >= 0.10.2)
6
6
  csv (~> 3.2)
7
7
  git (~> 3.0.0)
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.101.
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.101
260
+ 1.2.103
261
261
 
262
262
  GLOBAL OPTIONS
263
263
  -a, --add - Add a next action (deprecated, for backwards compatibility)
@@ -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
- options[:target_line] = target_line
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
- # Gather all candidate actions for selection
244
- candidate_actions = []
245
- targets_for_selection = []
246
- files = NA.find_files_matching({
247
- depth: options[:depth],
248
- done: options[:done],
249
- project: options[:project],
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
- tag: tags,
264
- done: options[:done]
256
+ regex: options[:regex],
257
+ require_na: false,
258
+ search: tokens,
259
+ tag: tags
265
260
  })
266
- todo.actions.each do |action_obj|
267
- # Format: filename:LINENUM:parent > action
268
- # Include line number in display for unique matching
269
- display = "#{File.basename(action_obj.file_path)}:#{action_obj.file_line}:#{action_obj.parent.join('>')} | #{action_obj.action}"
270
- candidate_actions << display
271
- targets_for_selection << { file: action_obj.file_path, line: action_obj.file_line, action: action_obj }
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
- # Multi-select using fzf or gum if available
276
- selected_indices = []
277
- if candidate_actions.any?
278
- selector = nil
279
- if TTY::Which.exist?('fzf')
280
- selector = 'fzf --multi --prompt="Select tasks> "'
281
- elsif TTY::Which.exist?('gum')
282
- selector = 'gum choose --no-limit'
283
- end
284
- if selector
285
- require 'open3'
286
- input = candidate_actions.join("\n")
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
- # Use popen3 to properly handle stdin for fzf
289
- Open3.popen3(selector) do |stdin, stdout, stderr, wait_thr|
290
- stdin.write(input)
291
- stdin.close
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
- output = stdout.read
299
+ output = stdout.read
294
300
 
295
- selected = output.split("\n").map(&:strip).reject(&:empty?)
301
+ selected = output.split("\n").map(&:strip).reject(&:empty?)
296
302
 
297
- # Track which candidates have been matched to avoid duplicates
298
- selected_indices = []
299
- candidate_actions.each_index do |i|
300
- if selected.include?(candidate_actions[i])
301
- selected_indices << i unless selected_indices.include?(i)
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
- # Apply update to selected actions
317
- actionable = [
318
- options[:note],
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
- if action_obj[:key] == :_plugin
394
- # Plugin selected directly from the main actions menu
395
- options[:plugin] = action_obj[:plugin_path]
396
- else
397
- # Prompt for parameter if needed
398
- param_value = nil
399
- # Only prompt for param if not :move (which has custom menu logic)
400
- if action_obj[:param] && action_obj[:key] != :move
401
- if TTY::Which.exist?('gum')
402
- gum = TTY::Which.which('gum')
403
- prompt = "Enter #{action_obj[:param]}: "
404
- param_value = `#{gum} input --placeholder "#{prompt}"`.strip
405
- else
406
- print "Enter #{action_obj[:param]}: "
407
- param_value = (STDIN.gets || '').strip
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
- # Set options for update
411
- case action_obj[:key]
412
- when :add_tag
413
- options[:tag] = [param_value]
414
- when :remove_tag
415
- options[:remove] = [param_value]
416
- when :delete
417
- options[:delete] = true
418
- when :finish
419
- options[:finish] = true
420
- # Timed finish? Prompt user for optional start/date inputs
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 = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
427
- start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
409
+ prompt = "Enter #{action_obj[:param]}: "
410
+ param_value = `#{gum} input --placeholder "#{prompt}"`.strip
428
411
  else
429
- print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
430
- start_expr = (STDIN.gets || '').strip
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
- selected_project = nil
453
- if move_selector
454
- require 'open3'
455
- input = project_menu.join("\n")
456
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
457
- selected_project = output.strip
458
- else
459
- puts 'Select a project:'
460
- project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
461
- idx = (STDIN.gets || '').strip.to_i - 1
462
- selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
463
- end
464
- if selected_project == 'New project'
465
- if TTY::Which.exist?('gum')
466
- gum = TTY::Which.which('gum')
467
- prompt = 'Enter new project name: '
468
- new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
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
- print 'Enter new project name: '
471
- new_proj_name = (STDIN.gets || '').strip
483
+ options[:move] = selected_project
472
484
  end
473
- # Create the new project in the file
474
- NA.insert_project(selected_file, new_proj_name)
475
- options[:move] = new_proj_name
476
- else
477
- options[:move] = selected_project
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
- end
489
- did_direct_update = false
490
-
491
- # Group selected actions by file for batch processing
492
- actions_by_file = {}
493
- selected_indices.each do |idx|
494
- file = targets_for_selection[idx][:file]
495
- actions_by_file[file] ||= []
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
- # Process each file's actions (non-plugin paths)
524
- actions_by_file.each do |file, action_list|
525
- # Rebuild all derived variables from options after menu-driven assignment
526
- add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
527
- remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
528
- remove_tags << 'done' if options[:restore]
529
- priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
530
- target_proj = if options[:move]
531
- options[:move]
532
- elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
533
- NA.cwd
534
- end
535
- note_val = note
536
- if options[:note] && defined?(param_value) && param_value
537
- note_val = [param_value]
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
- # Handle edit with multiple actions
541
- if options[:edit]
542
- # Open editor once with all actions for this file
543
- editor_content = NA::Editor.format_multi_action_input(action_list)
544
- edited_content = NA::Editor.fork_editor(editor_content)
545
- edited_actions = NA::Editor.parse_multi_action_output(edited_content)
546
-
547
- # If markers were removed but we have the same number of actions, match by position
548
- if edited_actions.empty? && action_list.size > 0
549
- # Parse content line by line, skipping comments and blanks
550
- non_comment_lines = edited_content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
551
-
552
- # Match each non-comment line to an action by position
553
- action_list.each_with_index do |action_obj, idx|
554
- if non_comment_lines[idx]
555
- # Split into action and notes
556
- lines = non_comment_lines[idx..-1]
557
- action_text = lines[0]
558
- note_lines = lines[1..-1] || []
559
-
560
- # Store by file:line key
561
- key = "#{action_obj.file_path}:#{action_obj.file_line}"
562
- edited_actions[key] = [action_text, note_lines]
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
- # Update each action with edited content
568
- action_list.each do |action_obj|
569
- key = "#{action_obj.file_path}:#{action_obj.file_line}"
570
- if edited_actions[key]
571
- action_obj.action, action_obj.note = edited_actions[key]
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
- # Update each action (process from bottom to top to avoid line shifts)
577
- action_list.sort_by(&:file_line).reverse.each do |action_obj|
578
- NA.update_action(file, nil,
579
- add: action_obj,
580
- add_tag: add_tags,
581
- all: true,
582
- append: append,
583
- delete: options[:delete],
584
- done: options[:done],
585
- edit: false, # Already handled above
586
- finish: options[:finish],
587
- move: target_proj,
588
- note: note_val,
589
- overwrite: options[:overwrite],
590
- priority: priority,
591
- project: options[:project],
592
- remove_tag: remove_tags,
593
- replace: options[:replace],
594
- search_note: options[:search_notes],
595
- tagged: nil)
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/bin/na CHANGED
@@ -9,6 +9,8 @@ require 'na/benchmark'
9
9
  require 'fcntl'
10
10
  require 'tempfile'
11
11
 
12
+ ORIGINAL_ARGV = ARGV.dup
13
+
12
14
  NA::Benchmark.init
13
15
  NA::Benchmark.measure('Gem loading') { nil } # Measures time up to this point
14
16
 
@@ -116,7 +118,7 @@ class App
116
118
  NA::Plugins.ensure_plugins_home
117
119
  NA.verbose = global[:debug]
118
120
  NA::Pager.paginate = global[:pager] && $stdout.isatty
119
- NA::Color.coloring = global[:color] && ($stdout.isatty || NA.globals.include?('--color'))
121
+ NA::Color.coloring = global[:color] && ($stdout.isatty || ORIGINAL_ARGV.include?('--color'))
120
122
  NA.extension = global[:ext]
121
123
  NA.include_ext = global[:include_ext]
122
124
  NA.na_tag = global[:na_tag]
@@ -175,7 +177,7 @@ class App
175
177
  on_error do |exception|
176
178
  case exception
177
179
  when GLI::UnknownCommand
178
- if NA.command_line.count == 1
180
+ if NA.command_line.one?
179
181
  cmd = ['saved']
180
182
  cmd.concat(ARGV.unshift(NA.command_line[0]))
181
183
 
@@ -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
- todo = NA::Todo.new({ search: search,
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
- return [todo.projects, todo.actions] if todo.actions.one? || all
282
-
283
- # If target_line is specified, find the action at that specific line
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
@@ -5,5 +5,5 @@
5
5
  module Na
6
6
  ##
7
7
  # Current version of the na gem.
8
- VERSION = '1.2.101'
8
+ VERSION = '1.2.103'
9
9
  end
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.100<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.102<!--END VER-->.
13
13
 
14
14
  <!--GITHUB-->
15
15
  ### Table of contents
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: na
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.101
4
+ version: 1.2.103
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra