na 1.2.85 → 1.2.86

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: da5eb39ed44c356c713172455a60fe197de3d1d1f00dc2eedadeb24fe40c9dde
4
- data.tar.gz: 5e58a4e4c17fac35a091b3cc49e7dca7aca06289b1fbc897be752bb4cdb93817
3
+ metadata.gz: 05b84c85234711e95bc6fae91b279577d0908ba7bc0b1fb254729d65c2823f99
4
+ data.tar.gz: acf70b87b92d32a81ae7802397e3f72fd72b28410c50556c680ad0f05c6ea21f
5
5
  SHA512:
6
- metadata.gz: 053e5636721b5c9544e22099840ef279462cbba96bd8f561c6a37c3f5b5453d7872bf549da8e8f78009f2c0a417805711ee3c7eebe2dfdf05538bbad84f7aafc
7
- data.tar.gz: 4626f2c6808056d9de394bd1eec109c0c56a8622015157ecc95bdf555523729254735550a775ae4542b5c20023d7bfc152152db0cf042661e07e5ec6ef1bc9ef
6
+ metadata.gz: 627981de849f31ca58ba38ff7e40915baf7cbf32f4a78d005f35a2590b853a477e28e71c209b5974cb9f4eae311d3f9e4c14c4fd690a253c16584bcac7d48fb1
7
+ data.tar.gz: 362e2719a19069a27cad8937d3ed7cabd81a7fd57a2b9f6a1dfa72ff439adf3fd64e691f651ad46020cdd4cdd08d0b6161b93adfcaf75c96040cf75451397a2f
File without changes
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2025-10-26 12:18:27 UTC using RuboCop version 1.75.7.
3
+ # on 2025-10-27 21:36:52 UTC using RuboCop version 1.75.7.
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
@@ -21,46 +21,53 @@ Lint/UnusedMethodArgument:
21
21
  Exclude:
22
22
  - 'lib/na/string.rb'
23
23
 
24
- # Offense count: 35
24
+ # Offense count: 36
25
25
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
26
26
  Metrics/AbcSize:
27
- Max: 232
27
+ Max: 243
28
28
 
29
- # Offense count: 10
29
+ # Offense count: 9
30
30
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
31
31
  # AllowedMethods: refine
32
32
  Metrics/BlockLength:
33
33
  Max: 144
34
34
 
35
- # Offense count: 4
35
+ # Offense count: 5
36
36
  # Configuration parameters: CountComments, CountAsOne.
37
37
  Metrics/ClassLength:
38
- Max: 763
38
+ Max: 777
39
39
 
40
- # Offense count: 23
40
+ # Offense count: 25
41
41
  # Configuration parameters: AllowedMethods, AllowedPatterns.
42
42
  Metrics/CyclomaticComplexity:
43
- Max: 66
43
+ Max: 70
44
44
 
45
- # Offense count: 38
45
+ # Offense count: 40
46
46
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
47
47
  Metrics/MethodLength:
48
48
  Max: 146
49
49
 
50
- # Offense count: 2
50
+ # Offense count: 3
51
51
  # Configuration parameters: CountComments, CountAsOne.
52
52
  Metrics/ModuleLength:
53
- Max: 765
53
+ Max: 779
54
54
 
55
55
  # Offense count: 4
56
56
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
57
57
  Metrics/ParameterLists:
58
58
  Max: 19
59
59
 
60
- # Offense count: 22
60
+ # Offense count: 24
61
61
  # Configuration parameters: AllowedMethods, AllowedPatterns.
62
62
  Metrics/PerceivedComplexity:
63
- Max: 76
63
+ Max: 81
64
+
65
+ # Offense count: 1
66
+ # Configuration parameters: ForbiddenDelimiters.
67
+ # ForbiddenDelimiters: (?i-mx:(^|\s)(EO[A-Z]{1}|END)(\s|$))
68
+ Naming/HeredocDelimiterNaming:
69
+ Exclude:
70
+ - 'lib/na/editor.rb'
64
71
 
65
72
  # Offense count: 3
66
73
  # This cop supports unsafe autocorrection (--autocorrect-all).
@@ -69,21 +76,6 @@ Security/YAMLLoad:
69
76
  - 'lib/na/next_action.rb'
70
77
  - 'lib/na/theme.rb'
71
78
 
72
- # Offense count: 8
73
- # Configuration parameters: AllowedConstants.
74
- Style/Documentation:
75
- Exclude:
76
- - 'spec/**/*'
77
- - 'test/**/*'
78
- - 'lib/na/action.rb'
79
- - 'lib/na/array.rb'
80
- - 'lib/na/benchmark.rb'
81
- - 'lib/na/editor.rb'
82
- - 'lib/na/hash.rb'
83
- - 'lib/na/project.rb'
84
- - 'lib/na/theme.rb'
85
- - 'lib/na/todo.rb'
86
-
87
79
  # Offense count: 2
88
80
  # This cop supports safe autocorrection (--autocorrect).
89
81
  Style/IfUnlessModifier:
@@ -99,13 +91,21 @@ Style/ModuleFunction:
99
91
  Exclude:
100
92
  - 'lib/na/colors.rb'
101
93
 
94
+ # Offense count: 1
95
+ # This cop supports safe autocorrection (--autocorrect).
96
+ # Configuration parameters: EnforcedStyle.
97
+ # SupportedStyles: single_quotes, double_quotes
98
+ Style/StringLiteralsInInterpolation:
99
+ Exclude:
100
+ - 'lib/na/action.rb'
101
+
102
102
  # Offense count: 1
103
103
  # This cop supports safe autocorrection (--autocorrect).
104
104
  Style/YAMLFileRead:
105
105
  Exclude:
106
106
  - 'lib/na/theme.rb'
107
107
 
108
- # Offense count: 18
108
+ # Offense count: 22
109
109
  # This cop supports safe autocorrection (--autocorrect).
110
110
  # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
111
111
  # URISchemes: http, https
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ### 1.2.86
2
+
3
+ 2025-10-27 17:16
4
+
1
5
  ### 1.2.85
2
6
 
3
7
  2025-10-26 08:43
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source 'https://rubygems.org'
4
4
  gemspec
5
5
  gem 'rake'
6
+
7
+ gem "simplecov", "~> 0.22.0", group: :development
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- na (1.2.85)
4
+ na (1.2.86)
5
5
  chronic (~> 0.10, >= 0.10.2)
6
6
  git (~> 3.0.0)
7
7
  gli (~> 2.21.0)
@@ -36,6 +36,7 @@ GEM
36
36
  concurrent-ruby (1.3.5)
37
37
  connection_pool (2.5.4)
38
38
  diff-lcs (1.6.2)
39
+ docile (1.4.1)
39
40
  drb (2.2.3)
40
41
  git (3.0.2)
41
42
  activesupport (>= 5.0)
@@ -69,6 +70,12 @@ GEM
69
70
  rspec-support (~> 3.13.0)
70
71
  rspec-support (3.13.6)
71
72
  securerandom (0.4.1)
73
+ simplecov (0.22.0)
74
+ docile (~> 1.1)
75
+ simplecov-html (~> 0.11)
76
+ simplecov_json_formatter (~> 0.1)
77
+ simplecov-html (0.13.2)
78
+ simplecov_json_formatter (0.1.4)
72
79
  tty-cursor (0.7.1)
73
80
  tty-reader (0.9.0)
74
81
  tty-cursor (~> 0.7)
@@ -103,6 +110,7 @@ DEPENDENCIES
103
110
  rake
104
111
  rdoc (~> 4.3)
105
112
  rspec (~> 3.0)
113
+ simplecov (~> 0.22.0)
106
114
  tty-spinner (~> 0.9, >= 0.9.0)
107
115
 
108
116
  BUNDLED WITH
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.85.
12
+ The current version of `na` is 1.2.86.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
@@ -76,7 +76,7 @@ SYNOPSIS
76
76
  na [global options] command [command options] [arguments...]
77
77
 
78
78
  VERSION
79
- 1.2.85
79
+ 1.2.86
80
80
 
81
81
  GLOBAL OPTIONS
82
82
  -a, --add - Add a next action (deprecated, for backwards compatibility)
@@ -164,13 +164,33 @@ class App
164
164
 
165
165
  options[:exact] = true unless options[:replace].nil?
166
166
 
167
+ # Check for PATH:LINE format in arguments
168
+ target_file = nil
169
+ target_line = nil
170
+ if args.count.positive?
171
+ pathline_match = args.join(' ').strip.match(/^(.+):(\d+)$/)
172
+ if pathline_match
173
+ target_file = pathline_match[1]
174
+ target_line = pathline_match[2].to_i
175
+
176
+ # Verify file exists
177
+ if File.exist?(target_file)
178
+ options[:file] = target_file
179
+ options[:target_line] = target_line
180
+ action = nil # Skip search processing
181
+ else
182
+ NA.notify("#{NA.theme[:error]}File not found: #{target_file}", exit_code: 1)
183
+ end
184
+ end
185
+ end
186
+
167
187
  if args.count.positive?
168
188
  action = args.join(' ').strip
169
189
  else
170
190
  action = nil
171
191
  end
172
192
  tokens = nil
173
- if action && !action.empty?
193
+ if action && !action.empty? && !target_line
174
194
  if options[:exact]
175
195
  tokens = action
176
196
  elsif options[:regex]
@@ -218,10 +238,11 @@ class App
218
238
  done: options[:done]
219
239
  })
220
240
  todo.actions.each do |action_obj|
221
- # Format: filename:project:parent > action
222
- display = "#{File.basename(action_obj.file)}:#{action_obj.project}:#{action_obj.parent.join('>')} | #{action_obj.action}"
241
+ # Format: filename:LINENUM:parent > action
242
+ # Include line number in display for unique matching
243
+ display = "#{File.basename(action_obj.file_path)}:#{action_obj.file_line}:#{action_obj.parent.join('>')} | #{action_obj.action}"
223
244
  candidate_actions << display
224
- targets_for_selection << { file: action_obj.file, line: action_obj.line, action: action_obj }
245
+ targets_for_selection << { file: action_obj.file_path, line: action_obj.file_line, action: action_obj }
225
246
  end
226
247
  end
227
248
 
@@ -237,9 +258,24 @@ class App
237
258
  if selector
238
259
  require 'open3'
239
260
  input = candidate_actions.join("\n")
240
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
241
- selected = output.split("\n").map(&:strip).reject(&:empty?)
242
- selected_indices = candidate_actions.each_index.select { |i| selected.include?(candidate_actions[i]) }
261
+
262
+ # Use popen3 to properly handle stdin for fzf
263
+ Open3.popen3(selector) do |stdin, stdout, stderr, wait_thr|
264
+ stdin.write(input)
265
+ stdin.close
266
+
267
+ output = stdout.read
268
+
269
+ selected = output.split("\n").map(&:strip).reject(&:empty?)
270
+
271
+ # Track which candidates have been matched to avoid duplicates
272
+ selected_indices = []
273
+ candidate_actions.each_index do |i|
274
+ if selected.include?(candidate_actions[i])
275
+ selected_indices << i unless selected_indices.include?(i)
276
+ end
277
+ end
278
+ end
243
279
  else
244
280
  # Fallback: select all or prompt for search string
245
281
  selected_indices = (0...candidate_actions.size).to_a
@@ -326,12 +362,7 @@ class App
326
362
  when :finish
327
363
  options[:finish] = true
328
364
  when :edit
329
- # Open editor for the selected action and update its content
330
- edit_action = targets_for_selection[selected_indices.first][:action]
331
- editor_content = "#{edit_action.action}\n#{edit_action.note.join("\n")}"
332
- new_action, new_note = NA::Editor.format_input(NA::Editor.fork_editor(editor_content))
333
- edit_action.action = new_action
334
- edit_action.note = new_note
365
+ # Just set the flag - multi-action editor will handle it below
335
366
  options[:edit] = true
336
367
  when :priority
337
368
  options[:priority] = param_value
@@ -384,7 +415,17 @@ class App
384
415
  end
385
416
  end
386
417
  did_direct_update = false
418
+
419
+ # Group selected actions by file for batch processing
420
+ actions_by_file = {}
387
421
  selected_indices.each do |idx|
422
+ file = targets_for_selection[idx][:file]
423
+ actions_by_file[file] ||= []
424
+ actions_by_file[file] << targets_for_selection[idx][:action]
425
+ end
426
+
427
+ # Process each file's actions
428
+ actions_by_file.each do |file, action_list|
388
429
  # Rebuild all derived variables from options after menu-driven assignment
389
430
  add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
390
431
  remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
@@ -399,29 +440,64 @@ class App
399
440
  if options[:note] && defined?(param_value) && param_value
400
441
  note_val = [param_value]
401
442
  end
402
- # Pass the selected action object as 'add', set search to nil
403
- # Pass the exact selected action object to update_action, bypassing all search/filter logic
404
- target = targets_for_selection[idx][:file]
405
- action_obj = targets_for_selection[idx][:action]
406
- # Direct action mode: update only the selected action in the known file
407
- NA.update_action(target, nil,
408
- add: action_obj,
409
- add_tag: add_tags,
410
- all: true,
411
- append: append,
412
- delete: options[:delete],
413
- done: options[:done],
414
- edit: options[:edit],
415
- finish: options[:finish],
416
- move: target_proj,
417
- note: note_val,
418
- overwrite: options[:overwrite],
419
- priority: priority,
420
- project: options[:project],
421
- remove_tag: remove_tags,
422
- replace: options[:replace],
423
- search_note: options[:search_notes],
424
- tagged: nil)
443
+
444
+ # Handle edit with multiple actions
445
+ if options[:edit]
446
+ # Open editor once with all actions for this file
447
+ editor_content = NA::Editor.format_multi_action_input(action_list)
448
+ edited_content = NA::Editor.fork_editor(editor_content)
449
+ edited_actions = NA::Editor.parse_multi_action_output(edited_content)
450
+
451
+ # If markers were removed but we have the same number of actions, match by position
452
+ if edited_actions.empty? && action_list.size > 0
453
+ # Parse content line by line, skipping comments and blanks
454
+ non_comment_lines = edited_content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
455
+
456
+ # Match each non-comment line to an action by position
457
+ action_list.each_with_index do |action_obj, idx|
458
+ if non_comment_lines[idx]
459
+ # Split into action and notes
460
+ lines = non_comment_lines[idx..-1]
461
+ action_text = lines[0]
462
+ note_lines = lines[1..-1] || []
463
+
464
+ # Store by file:line key
465
+ key = "#{action_obj.file_path}:#{action_obj.file_line}"
466
+ edited_actions[key] = [action_text, note_lines]
467
+ end
468
+ end
469
+ end
470
+
471
+ # Update each action with edited content
472
+ action_list.each do |action_obj|
473
+ key = "#{action_obj.file_path}:#{action_obj.file_line}"
474
+ if edited_actions[key]
475
+ action_obj.action, action_obj.note = edited_actions[key]
476
+ end
477
+ end
478
+ end
479
+
480
+ # Update each action
481
+ action_list.each do |action_obj|
482
+ NA.update_action(file, nil,
483
+ add: action_obj,
484
+ add_tag: add_tags,
485
+ all: true,
486
+ append: append,
487
+ delete: options[:delete],
488
+ done: options[:done],
489
+ edit: false, # Already handled above
490
+ finish: options[:finish],
491
+ move: target_proj,
492
+ note: note_val,
493
+ overwrite: options[:overwrite],
494
+ priority: priority,
495
+ project: options[:project],
496
+ remove_tag: remove_tags,
497
+ replace: options[:replace],
498
+ search_note: options[:search_notes],
499
+ tagged: nil)
500
+ end
425
501
  did_direct_update = true
426
502
  end
427
503
  if did_direct_update
@@ -539,8 +615,15 @@ class App
539
615
 
540
616
  NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
541
617
 
618
+ # Handle target_line if provided (from PATH:LINE format)
619
+ search_tokens = if options[:target_line]
620
+ { target_line: options[:target_line] }
621
+ else
622
+ tokens
623
+ end
624
+
542
625
  targets.each do |target|
543
- NA.update_action(target, tokens,
626
+ NA.update_action(target, search_tokens,
544
627
  add_tag: add_tags,
545
628
  all: options[:all],
546
629
  append: append,
data/lib/na/action.rb CHANGED
@@ -14,7 +14,12 @@ module NA
14
14
  def initialize(file, project, parent, action, idx, note = [])
15
15
  super()
16
16
 
17
- @file = file
17
+ # Store file in PATH:LINE format if line is available
18
+ @file = if idx.is_a?(Integer)
19
+ "#{file}:#{idx}"
20
+ else
21
+ file
22
+ end
18
23
  @project = project
19
24
  @parent = parent
20
25
  @action = action.gsub('{', '\\{')
@@ -23,6 +28,32 @@ module NA
23
28
  @note = note
24
29
  end
25
30
 
31
+ # Extract file path and line number from PATH:LINE format
32
+ #
33
+ # @return [Array] [file_path, line_number]
34
+ def file_line_parts
35
+ if @file.to_s.include?(':')
36
+ path, line = @file.split(':', 2)
37
+ [path, line.to_i]
38
+ else
39
+ [@file, @line]
40
+ end
41
+ end
42
+
43
+ # Get just the file path without line number
44
+ #
45
+ # @return [String] File path
46
+ def file_path
47
+ file_line_parts.first
48
+ end
49
+
50
+ # Get the line number
51
+ #
52
+ # @return [Integer] Line number
53
+ def file_line
54
+ file_line_parts.last
55
+ end
56
+
26
57
  # Update the action string and note with priority, tags, and completion status
27
58
  #
28
59
  # @param priority [Integer] Priority value to set
@@ -70,7 +101,7 @@ module NA
70
101
  else
71
102
  ''
72
103
  end
73
- "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
104
+ "(#{file_path}:#{file_line}) #{@project}:#{@parent.join(">")} | #{@action}#{note}"
74
105
  end
75
106
 
76
107
  # Pretty string representation of the action with color formatting
@@ -82,7 +113,7 @@ module NA
82
113
  else
83
114
  ''
84
115
  end
85
- "{x}#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}{x}#{NA.theme[:bracket]}[{x}#{NA.theme[:project]}#{@project}:#{@parent.join('>')}{x}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}{x}#{NA.theme[:note]}#{note}"
116
+ "{x}#{NA.theme[:filename]}#{File.basename(file_path)}:#{file_line}{x}#{NA.theme[:bracket]}[{x}#{NA.theme[:project]}#{@project}:#{@parent.join('>')}{x}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}{x}#{NA.theme[:note]}#{note}"
86
117
  end
87
118
 
88
119
  # Inspect the action object
@@ -112,49 +143,67 @@ module NA
112
143
  NA::Benchmark.measure('Action.pretty') do
113
144
  # Use cached theme instead of loading every time
114
145
  theme = NA.theme
115
- template = theme.merge(template)
146
+ # Merge templates if provided
147
+ if template[:templates]
148
+ theme = theme.dup
149
+ theme[:templates] = theme[:templates].merge(template[:templates])
150
+ template = theme.merge(template.reject { |k| k == :templates })
151
+ else
152
+ template = theme.merge(template)
153
+ end
116
154
 
117
155
  # Pre-compute common template parts to avoid repeated processing
118
156
  output_template = template[:templates][:output]
119
157
  needs_filename = output_template.include?('%filename')
158
+ needs_line = output_template.include?('%line')
120
159
  needs_parents = output_template.include?('%parents') || output_template.include?('%parent')
121
160
  needs_project = output_template.include?('%project')
122
161
 
123
162
  # Create the hierarchical parent string (optimized)
124
163
  parents = if needs_parents && @parent.any?
125
164
  parent_parts = @parent.map { |par| "#{template[:parent]}#{par}" }.join(template[:parent_divider])
126
- NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}{x}#{template[:bracket]}]{x} ")
165
+ # parent_parts already has color codes embedded, create final string directly
166
+ "#{NA::Color.template("{x}#{template[:bracket]}[")}#{parent_parts}#{NA::Color.template("{x}#{template[:bracket]}]{x}")}"
127
167
  else
128
168
  ''
129
169
  end
130
170
 
131
- # Create the project string (optimized)
171
+ # Create the project string (optimized) - Ensure color reset before project
132
172
  project = if needs_project && !@project.empty?
133
- NA::Color.template("{x}#{template[:project]}#{@project}{x} ")
173
+ NA::Color.template("{x}#{template[:project]}#{@project}{x}")
134
174
  else
135
175
  ''
136
176
  end
137
177
 
138
178
  # Create the source filename string (optimized)
139
179
  filename = if needs_filename
140
- path = @file ? @file.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~') : ''
180
+ path_only = file_path # Extract just the path from PATH:LINE
181
+ path = path_only.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~')
141
182
  if File.dirname(path) == '.'
142
183
  fname = NA.include_ext ? File.basename(path) : File.basename(path, ".#{extension}")
143
184
  fname = "./#{fname}" if NA.show_cwd_indicator
144
- NA::Color.template("#{template[:filename]}#{fname} {x}")
185
+ NA::Color.template("#{template[:filename]}#{fname}{x}")
145
186
  else
146
187
  colored = (NA.include_ext ? path : path.sub(/\.#{extension}$/, '')).highlight_filename
147
- NA::Color.template("#{template[:filename]}#{colored} {x}")
188
+ NA::Color.template("#{template[:filename]}#{colored}{x}")
148
189
  end
149
190
  else
150
191
  ''
151
192
  end
152
193
 
194
+ # Create the line number string (optimized)
195
+ line_num = if needs_line && @line
196
+ NA::Color.template("#{template[:line]}:#{@line} {x}")
197
+ else
198
+ ''
199
+ end
200
+
153
201
  # colorize the action and highlight tags (optimized)
154
202
  action_text = @action.dup
155
203
  action_text.gsub!(/\{(.*?)\}/, '\\{\1\\}')
156
204
  action_text = action_text.sub(/ @#{NA.na_tag}\b/, '')
157
- action = NA::Color.template("#{template[:action]}#{action_text}{x}")
205
+ # Reset colors before action to prevent bleeding from parents/project
206
+ action = NA::Color.template("{x}#{template[:action]}#{action_text}{x}")
158
207
  action = action.highlight_tags(color: template[:tags],
159
208
  parens: template[:value_parens],
160
209
  value: template[:values],
@@ -169,8 +218,8 @@ module NA
169
218
  width = @cached_width ||= TTY::Screen.columns
170
219
  # Calculate indent more efficiently - avoid repeated template processing
171
220
  base_template = output_template.gsub('%action', '').gsub('%note', '')
172
- base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/,
173
- parents)
221
+ base_output = base_template.gsub('%filename', filename).gsub('%line', line_num).gsub('%project', project).gsub(/%parents?/,
222
+ parents)
174
223
  indent = NA::Color.uncolor(NA::Color.template(base_output)).length
175
224
  note = NA::Color.template(@note.wrap(width, indent, template[:note]))
176
225
  else
@@ -185,7 +234,7 @@ module NA
185
234
  if detect_width && !action.empty?
186
235
  width = @cached_width ||= TTY::Screen.columns
187
236
  base_template = output_template.gsub('%action', '').gsub('%note', '')
188
- base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/, parents)
237
+ base_output = base_template.gsub('%filename', filename).gsub('%line', line_num).gsub('%project', project).gsub(/%parents?/, parents)
189
238
  indent = NA::Color.uncolor(NA::Color.template(base_output)).length
190
239
  action = action.wrap(width, indent)
191
240
  end
@@ -193,6 +242,7 @@ module NA
193
242
  # Replace variables in template string and output colorized (optimized)
194
243
  final_output = output_template.dup
195
244
  final_output.gsub!('%filename', filename)
245
+ final_output.gsub!('%line', line_num)
196
246
  final_output.gsub!('%project', project)
197
247
  final_output.gsub!(/%parents?/, parents)
198
248
  final_output.gsub!('%action', action.highlight_search(regexes))
data/lib/na/editor.rb CHANGED
@@ -100,7 +100,12 @@ module NA
100
100
  tmpfile.unlink
101
101
  end
102
102
 
103
- input.split("\n").delete_if(&:ignore?).join("\n")
103
+ # Don't strip comments if this looks like multi-action format (has # ------ markers)
104
+ if input.include?('# ------ ')
105
+ input
106
+ else
107
+ input.split("\n").delete_if(&:ignore?).join("\n")
108
+ end
104
109
  end
105
110
 
106
111
  # Takes a multi-line string and formats it as an entry
@@ -129,6 +134,80 @@ module NA
129
134
 
130
135
  [title, note]
131
136
  end
137
+
138
+ # Format multiple actions for multi-edit
139
+ # @param actions [Array<Action>] Actions to edit
140
+ # @return [String] Formatted editor content
141
+ def format_multi_action_input(actions)
142
+ header = <<~EOF
143
+ # Instructions:
144
+ # - Edit the action text (the lines WITHOUT # comment markers)
145
+ # - DO NOT remove or edit the lines starting with "# ------"
146
+ # - Add notes on new lines after the action
147
+ # - Blank lines are ignored
148
+ #
149
+
150
+ EOF
151
+
152
+ # Use + to create a mutable string
153
+ content = +header
154
+
155
+ actions.each do |action|
156
+ # Use file_path to get the path and file_line to get the line number
157
+ content << "# ------ #{action.file_path}:#{action.file_line}\n"
158
+ content << "#{action.action}\n"
159
+ content << "#{action.note.join("\n")}\n" if action.note.any?
160
+ content << "\n" # Blank line separator
161
+ end
162
+
163
+ content
164
+ end
165
+
166
+ # Parse multi-action editor output
167
+ # @param content [String] Editor output
168
+ # @return [Hash] Hash mapping file:line to [action, note]
169
+ def parse_multi_action_output(content)
170
+ results = {}
171
+ current_file = nil
172
+ current_action = nil
173
+ current_note = []
174
+
175
+ content.lines.each do |line|
176
+ stripped = line.strip
177
+
178
+ # Check for file marker: # ------ path:line
179
+ match = stripped.match(/^# ------ (.+?):(\d+)$/)
180
+ if match
181
+ # Save previous action if exists
182
+ results[current_file] = [current_action, current_note] if current_file && current_action
183
+
184
+ # Start new action
185
+ current_file = "#{match[1]}:#{match[2]}"
186
+ current_action = nil
187
+ current_note = []
188
+ next
189
+ end
190
+
191
+ # Skip other comment lines
192
+ next if stripped.start_with?('#')
193
+
194
+ # Skip blank lines
195
+ next if stripped.empty?
196
+
197
+ # Store as action or note based on what we've seen so far
198
+ if current_action.nil?
199
+ current_action = stripped
200
+ else
201
+ # Subsequent lines are notes
202
+ current_note << stripped
203
+ end
204
+ end
205
+
206
+ # Save last action
207
+ results[current_file] = [current_action, current_note] if current_file && current_action
208
+
209
+ results
210
+ end
132
211
  end
133
212
  end
134
213
  end
@@ -169,8 +169,9 @@ module NA
169
169
  # @param done [Boolean] Include done actions
170
170
  # @param project [String, nil] Project name
171
171
  # @param search_note [Boolean] Search notes
172
+ # @param target_line [Integer] Specific line number to target
172
173
  # @return [Array] Projects and actions
173
- def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
174
+ def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil)
174
175
  todo = NA::Todo.new({ search: search,
175
176
  search_note: search_note,
176
177
  require_na: false,
@@ -187,7 +188,17 @@ module NA
187
188
 
188
189
  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
189
190
 
190
- options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
191
+ # If target_line is specified, find the action at that specific line
192
+ if target_line
193
+ matching_action = todo.actions.find { |a| a.line == target_line }
194
+ return [todo.projects, NA::Actions.new([matching_action])] if matching_action
195
+
196
+ NA.notify("#{NA.theme[:error]}No action found at line #{target_line}", exit_code: 1)
197
+ return [todo.projects, NA::Actions.new]
198
+
199
+ end
200
+
201
+ options = todo.actions.map { |action| "#{action.file} : #{action.action}" }
191
202
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
192
203
 
193
204
  unless res&.length&.positive?
@@ -197,9 +208,14 @@ module NA
197
208
 
198
209
  selected = NA::Actions.new
199
210
  res.each do |result|
200
- idx = result.match(/^(\d+)(?= % )/)[1]
201
- action = todo.actions.select { |a| a.line == idx.to_i }.first
202
- selected.push(action)
211
+ # Extract file:line from result (e.g., "./todo.taskpaper:21 : action text")
212
+ match = result.match(/^(.+?):(\d+) : /)
213
+ next unless match
214
+
215
+ file_path = match[1]
216
+ line_num = match[2].to_i
217
+ action = todo.actions.select { |a| a.file_path == file_path && a.file_line == line_num }.first
218
+ selected.push(action) if action
203
219
  end
204
220
  [todo.projects, selected]
205
221
  end
@@ -312,6 +328,9 @@ module NA
312
328
  remove_tag: [],
313
329
  replace: nil,
314
330
  tagged: nil)
331
+ # Expand target to absolute path to avoid path resolution issues
332
+ target = File.expand_path(target) unless Pathname.new(target).absolute?
333
+
315
334
  projects = find_projects(target)
316
335
  affected_actions = []
317
336
 
@@ -336,11 +355,14 @@ module NA
336
355
  contents = target.read_file.split("\n")
337
356
 
338
357
  if add.is_a?(Action)
358
+ # NOTE: Edit is handled in the update command before calling update_action
359
+ # So we don't need to handle it here - the action is already edited
360
+
339
361
  add_tag ||= []
340
362
  add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
341
363
 
342
364
  # Remove the original action and its notes
343
- action_line = add.line
365
+ action_line = add.file_line
344
366
  note_lines = add.note.is_a?(Array) ? add.note.count : 0
345
367
  contents.slice!(action_line, note_lines + 1)
346
368
 
@@ -389,27 +411,36 @@ module NA
389
411
  changes << "moved to #{target_proj.project}" if move && target_proj
390
412
  affected_actions << { action: add, desc: changes.join(', ') }
391
413
  else
414
+ # Check if search is actually target_line
415
+ target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
392
416
  _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
393
- search_note: search_note)
417
+ search_note: search_note, target_line: target_line)
394
418
 
395
419
  return if actions.nil?
396
420
 
397
- actions.sort_by(&:line).reverse.each do |action|
398
- contents.slice!(action.line, action.note.count + 1)
421
+ # Handle edit (single or multi-action)
422
+ if edit
423
+ editor_content = Editor.format_multi_action_input(actions)
424
+ edited_content = Editor.fork_editor(editor_content)
425
+ edited_actions = Editor.parse_multi_action_output(edited_content)
426
+
427
+ # Map edited content back to actions
428
+ actions.each do |action|
429
+ # Use file_path:file_line as the key
430
+ key = "#{action.file_path}:#{action.file_line}"
431
+ action.action, action.note = edited_actions[key] if edited_actions[key]
432
+ end
433
+ end
434
+
435
+ actions.sort_by(&:file_line).reverse.each do |action|
436
+ contents.slice!(action.file_line, action.note.count + 1)
399
437
  if delete
400
438
  # Track deletion before skipping re-insert
401
439
  affected_actions << { action: action, desc: 'deleted' }
402
440
  next
403
441
  end
404
442
 
405
- projects = shift_index_after(projects, action.line, action.note.count + 1)
406
-
407
- if edit
408
- editor_content = "#{action.action}\n#{action.note.join("\n")}"
409
- new_action, new_note = Editor.format_input(Editor.fork_editor(editor_content))
410
- action.action = new_action
411
- action.note = new_note
412
- end
443
+ projects = shift_index_after(projects, action.file_line, action.note.count + 1)
413
444
 
414
445
  # If replace is defined, use search to search and replace text in action
415
446
  action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
data/lib/na/string.rb CHANGED
@@ -149,13 +149,17 @@ class ::String
149
149
  # @param color [String] The highlight color template
150
150
  # @param last_color [String] Color to restore after highlight
151
151
  def highlight_search(regexes, color: NA.theme[:search_highlight], last_color: NA.theme[:action])
152
+ # Skip if string already contains ANSI codes - applying regex to colored text
153
+ # will break escape sequences (e.g., searching for "3" will match "3" in "38;2;236;204;135m")
154
+ return self if include?("\e")
155
+
156
+ # Original simple approach for strings without ANSI codes
152
157
  string = dup
153
158
  color = NA::Color.template(color.dup)
154
159
  regexes.each do |rx|
155
160
  next if rx.nil?
156
161
 
157
162
  rx = Regexp.new(rx, Regexp::IGNORECASE) if rx.is_a?(String)
158
-
159
163
  string.gsub!(rx) do
160
164
  m = Regexp.last_match
161
165
  last = m.pre_match.last_color
@@ -190,9 +194,9 @@ class ::String
190
194
  output = []
191
195
  line = []
192
196
  length = 0
193
- gsub!(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
197
+ text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
194
198
 
195
- split(' ').each do |word|
199
+ text.split.each do |word|
196
200
  uncolored = NA::Color.uncolor(word)
197
201
  if (length + uncolored.length + 1) <= width
198
202
  line << word
data/lib/na/theme.rb CHANGED
@@ -37,51 +37,60 @@ module NA
37
37
  # @example
38
38
  # NA::Theme.load_theme(template: { action: '{r}' })
39
39
  def load_theme(template: {})
40
- NA::Benchmark.measure('Theme.load_theme') do
41
- # Default colorization, can be overridden with full or partial template variable
42
- default_template = {
43
- parent: '{c}',
44
- bracket: '{dc}',
45
- parent_divider: '{xw}/',
46
- action: '{bg}',
47
- project: '{xbk}',
48
- tags: '{m}',
49
- value_parens: '{m}',
50
- values: '{c}',
51
- search_highlight: '{y}',
52
- note: '{dw}',
53
- dirname: '{xdw}',
54
- filename: '{xb}{#eccc87}',
55
- prompt: '{m}',
56
- success: '{bg}',
57
- error: '{b}{#b61d2a}',
58
- warning: '{by}',
59
- debug: '{dw}',
60
- templates: {
61
- output: '%filename%parents| %action',
62
- default: '%parent%action',
63
- single_file: '%parent%action',
64
- multi_file: '%filename%parent%action',
65
- no_file: '%parent%action'
66
- }
67
- }
40
+ if defined?(NA::Benchmark) && NA::Benchmark
41
+ NA::Benchmark.measure('Theme.load_theme') do
42
+ load_theme_internal(template: template)
43
+ end
44
+ else
45
+ load_theme_internal(template: template)
46
+ end
47
+ end
68
48
 
69
- # Load custom theme
70
- theme_file = NA.database_path(file: 'theme.yaml')
71
- theme = if File.exist?(theme_file)
72
- YAML.load(File.read(theme_file)) || {}
73
- else
74
- {}
75
- end
76
- theme = default_template.deep_merge(theme)
49
+ def load_theme_internal(template: {})
50
+ # Default colorization, can be overridden with full or partial template variable
51
+ default_template = {
52
+ parent: '{c}',
53
+ bracket: '{dc}',
54
+ parent_divider: '{xw}/',
55
+ action: '{bg}',
56
+ project: '{xbk}',
57
+ tags: '{m}',
58
+ value_parens: '{m}',
59
+ values: '{c}',
60
+ search_highlight: '{y}',
61
+ note: '{dw}',
62
+ dirname: '{xdw}',
63
+ filename: '{xb}{#eccc87}',
64
+ line: '{dw}',
65
+ prompt: '{m}',
66
+ success: '{bg}',
67
+ error: '{b}{#b61d2a}',
68
+ warning: '{by}',
69
+ debug: '{dw}',
70
+ templates: {
71
+ output: '%filename%line%parents| %action',
72
+ default: '%parents %line %action',
73
+ single_file: '%parents %line %action',
74
+ multi_file: '%filename%line%parents %action',
75
+ no_file: '%parents %line %action'
76
+ }
77
+ }
77
78
 
78
- File.open(theme_file, 'w') do |f|
79
- f.puts template_help.comment
80
- f.puts YAML.dump(theme)
81
- end
79
+ # Load custom theme
80
+ theme_file = NA.database_path(file: 'theme.yaml')
81
+ theme = if File.exist?(theme_file)
82
+ YAML.load(File.read(theme_file)) || {}
83
+ else
84
+ {}
85
+ end
86
+ theme = default_template.deep_merge(theme)
82
87
 
83
- theme.merge(template)
88
+ File.open(theme_file, 'w') do |f|
89
+ f.puts template_help.comment
90
+ f.puts YAML.dump(theme)
84
91
  end
92
+
93
+ theme.merge(template)
85
94
  end
86
95
  end
87
96
  end
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.85'
8
+ VERSION = '1.2.86'
9
9
  end
data/lib/na.rb CHANGED
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'na/benchmark'
3
+ require 'na/benchmark' if ENV['NA_BENCHMARK']
4
+ # Define a dummy Benchmark if not available for tests
5
+ unless defined?(NA::Benchmark)
6
+ module NA
7
+ module Benchmark
8
+ def self.measure(_label)
9
+ yield
10
+ end
11
+ end
12
+ end
13
+ end
4
14
  require 'na/version'
5
15
  require 'na/pager'
6
16
  require 'time'
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.84<!--END VER-->.
12
+ The current version of `na` is <!--VER-->1.2.85<!--END VER-->.
13
13
 
14
14
  `na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
15
15
 
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.85
4
+ version: 1.2.86
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -245,6 +245,7 @@ extra_rdoc_files:
245
245
  - README.md
246
246
  - na.rdoc
247
247
  files:
248
+ - ".cursor/commands/priority35m36m335m32m.md"
248
249
  - ".rubocop.yml"
249
250
  - ".rubocop_todo.yml"
250
251
  - ".travis.yml"