na 1.2.80 → 1.2.82

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.
@@ -104,6 +104,49 @@ class App
104
104
  c.switch %i[x exact], negatable: false
105
105
 
106
106
  c.action do |global_options, options, args|
107
+ # Ensure all variables used in update loop are declared
108
+ target_proj = if options[:move]
109
+ options[:move]
110
+ elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
111
+ NA.cwd
112
+ end
113
+
114
+ priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
115
+ remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').respond_to?(:wildcard_to_rx) ? t.wildcard_to_rx : t } : []
116
+ remove_tags << 'done' if options[:restore]
117
+
118
+ stdin_note = NA.respond_to?(:stdin) && NA.stdin ? NA.stdin.split("\n") : []
119
+ line_note = if options[:note] && $stdin.isatty
120
+ puts stdin_note unless stdin_note.nil?
121
+ if TTY::Which.exist?('gum')
122
+ args = ['--placeholder "Enter a note, CTRL-d to save"']
123
+ args << '--char-limit 0'
124
+ args << '--width $(tput cols)'
125
+ gum = TTY::Which.which('gum')
126
+ `#{gum} write #{args.join(' ')}`.strip.split("\n")
127
+ else
128
+ NA.notify("#{NA.theme[:prompt]}Enter a note, {bw}CTRL-d#{NA.theme[:prompt]} to end editing:#{NA.theme[:action]}")
129
+ reader.read_multiline
130
+ end
131
+ end
132
+ note = stdin_note.empty? ? [] : stdin_note
133
+ note.concat(line_note) unless line_note.nil? || line_note.empty?
134
+
135
+ append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
136
+ add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '').respond_to?(:wildcard_to_rx) ? t.wildcard_to_rx : t } : []
137
+ # Build tags array from options[:tagged]
138
+ all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
139
+ tags = []
140
+ options[:tagged].join(',').split(/ *, */).each do |arg|
141
+ m = arg.match(/^(?<req>[+!-])?(?<tag>[^ =<>$~\^]+?) *(?:(?<op>[=<>~]{1,2}|[*$\^]=) *(?<val>.*?))?$/)
142
+ tags.push({
143
+ tag: m['tag'].respond_to?(:wildcard_to_rx) ? m['tag'].wildcard_to_rx : m['tag'],
144
+ comp: m['op'],
145
+ value: m['val'],
146
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
147
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
148
+ })
149
+ end
107
150
  reader = TTY::Reader.new
108
151
 
109
152
  args.concat(options[:search]) unless options[:search].nil?
@@ -121,13 +164,13 @@ class App
121
164
 
122
165
  options[:exact] = true unless options[:replace].nil?
123
166
 
124
- action = if args.count.positive?
125
- args.join(' ').strip
126
- else
127
- NA.request_input(options, prompt: 'Enter a task to search for')
128
- end
129
- if action
130
- tokens = nil
167
+ if args.count.positive?
168
+ action = args.join(' ').strip
169
+ else
170
+ action = nil
171
+ end
172
+ tokens = nil
173
+ if action && !action.empty?
131
174
  if options[:exact]
132
175
  tokens = action
133
176
  elsif options[:regex]
@@ -135,20 +178,254 @@ class App
135
178
  else
136
179
  tokens = []
137
180
  all_req = action !~ /[+!-]/ && !options[:or]
138
-
139
181
  action.split(/ /).each do |arg|
140
182
  m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
141
183
  tokens.push({
142
- token: m['tok'],
143
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
144
- negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
145
- })
184
+ token: m['tok'],
185
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
186
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/ ? true : false
187
+ })
146
188
  end
147
189
  end
148
190
  end
149
191
 
192
+ # If no search query or tags, list all tasks for selection
150
193
  if (action.nil? || action.empty?) && options[:tagged].empty?
151
- NA.notify("#{NA.theme[:error]}Empty input, cancelled", exit_code: 1)
194
+ tokens = nil # No search, list all
195
+ end
196
+
197
+ # Gather all candidate actions for selection
198
+ candidate_actions = []
199
+ targets_for_selection = []
200
+ files = NA.find_files_matching({
201
+ depth: options[:depth],
202
+ done: options[:done],
203
+ project: options[:project],
204
+ regex: options[:regex],
205
+ require_na: false,
206
+ search: tokens,
207
+ tag: tags
208
+ })
209
+ files.each do |file|
210
+ safe_search = (tokens.is_a?(String) || tokens.is_a?(Array) || tokens.is_a?(Regexp)) ? tokens : nil
211
+ todo = NA::Todo.new({
212
+ search: safe_search,
213
+ search_note: options[:search_notes],
214
+ require_na: false,
215
+ file_path: file,
216
+ project: options[:project],
217
+ tag: tags,
218
+ done: options[:done]
219
+ })
220
+ 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}"
223
+ candidate_actions << display
224
+ targets_for_selection << { file: action_obj.file, line: action_obj.line, action: action_obj }
225
+ end
226
+ end
227
+
228
+ # Multi-select using fzf or gum if available
229
+ selected_indices = []
230
+ if candidate_actions.any?
231
+ selector = nil
232
+ if TTY::Which.exist?('fzf')
233
+ selector = 'fzf --multi --prompt="Select tasks> "'
234
+ elsif TTY::Which.exist?('gum')
235
+ selector = 'gum choose --no-limit'
236
+ end
237
+ if selector
238
+ require 'open3'
239
+ 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]) }
243
+ else
244
+ # Fallback: select all or prompt for search string
245
+ selected_indices = (0...candidate_actions.size).to_a
246
+ end
247
+ end
248
+
249
+ # If no actions found, notify and exit
250
+ if selected_indices.empty?
251
+ NA.notify("#{NA.theme[:error]}No matching actions found for selection", exit_code: 1)
252
+ end
253
+
254
+ # Apply update to selected actions
255
+ actionable = [
256
+ options[:note],
257
+ (options[:priority].to_i if options[:priority]).to_i.positive?,
258
+ !options[:move].to_s.empty?,
259
+ !(options[:tag].nil? || options[:tag].empty?),
260
+ !(options[:remove].nil? || options[:remove].empty?),
261
+ !options[:replace].to_s.empty?,
262
+ options[:finish],
263
+ options[:archive],
264
+ options[:restore],
265
+ options[:delete],
266
+ options[:edit]
267
+ ].any?
268
+ unless actionable
269
+ # Interactive menu for actions
270
+ actions_menu = [
271
+ { key: :add_tag, label: 'Add Tag', param: 'Tag' },
272
+ { key: :remove_tag, label: 'Remove Tag', param: 'Tag' },
273
+ { key: :delete, label: 'Delete', param: nil },
274
+ { key: :finish, label: 'Finish (mark done)', param: nil },
275
+ { key: :edit, label: 'Edit', param: nil },
276
+ { key: :priority, label: 'Set Priority', param: 'Priority (1-5)' },
277
+ { key: :move, label: 'Move to Project', param: 'Project' },
278
+ { key: :restore, label: 'Restore', param: nil },
279
+ { key: :archive, label: 'Archive', param: nil },
280
+ { key: :note, label: 'Add Note', param: 'Note' }
281
+ ]
282
+ selector = nil
283
+ if TTY::Which.exist?('fzf')
284
+ selector = 'fzf --prompt="Select action> "'
285
+ elsif TTY::Which.exist?('gum')
286
+ selector = 'gum choose'
287
+ end
288
+ menu_labels = actions_menu.map { |a| a[:label] }
289
+ selected_action = nil
290
+ if selector
291
+ require 'open3'
292
+ input = menu_labels.join("\n")
293
+ output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
294
+ selected_action = output.strip
295
+ else
296
+ puts 'Select an action:'
297
+ menu_labels.each_with_index { |label, i| puts "#{i+1}. #{label}" }
298
+ idx = (STDIN.gets || '').strip.to_i - 1
299
+ selected_action = menu_labels[idx] if idx >= 0 && idx < menu_labels.size
300
+ end
301
+ action_obj = actions_menu.find { |a| a[:label] == selected_action }
302
+ if action_obj.nil?
303
+ NA.notify("#{NA.theme[:error]}No action selected, cancelled", exit_code: 1)
304
+ end
305
+ # Prompt for parameter if needed
306
+ param_value = nil
307
+ # Only prompt for param if not :move (which has custom menu logic)
308
+ if action_obj[:param] && action_obj[:key] != :move
309
+ if TTY::Which.exist?('gum')
310
+ gum = TTY::Which.which('gum')
311
+ prompt = "Enter #{action_obj[:param]}: "
312
+ param_value = `#{gum} input --placeholder "#{prompt}"`.strip
313
+ else
314
+ print "Enter #{action_obj[:param]}: "
315
+ param_value = (STDIN.gets || '').strip
316
+ end
317
+ end
318
+ # Set options for update
319
+ case action_obj[:key]
320
+ when :add_tag
321
+ options[:tag] = [param_value]
322
+ when :remove_tag
323
+ options[:remove] = [param_value]
324
+ when :delete
325
+ options[:delete] = true
326
+ when :finish
327
+ options[:finish] = true
328
+ 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
335
+ options[:edit] = true
336
+ when :priority
337
+ options[:priority] = param_value
338
+ when :move
339
+ # Gather projects from the same file as the selected action
340
+ selected_file = targets_for_selection[selected_indices.first][:file]
341
+ todo = NA::Todo.new(file_path: selected_file)
342
+ project_names = todo.projects.map { |proj| proj.project }
343
+ project_menu = project_names + ['New project']
344
+ move_selector = nil
345
+ if TTY::Which.exist?('fzf')
346
+ move_selector = 'fzf --prompt="Select project> "'
347
+ elsif TTY::Which.exist?('gum')
348
+ move_selector = 'gum choose'
349
+ end
350
+ selected_project = nil
351
+ if move_selector
352
+ require 'open3'
353
+ input = project_menu.join("\n")
354
+ output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{move_selector}")
355
+ selected_project = output.strip
356
+ else
357
+ puts 'Select a project:'
358
+ project_menu.each_with_index { |label, i| puts "#{i+1}. #{label}" }
359
+ idx = (STDIN.gets || '').strip.to_i - 1
360
+ selected_project = project_menu[idx] if idx >= 0 && idx < project_menu.size
361
+ end
362
+ if selected_project == 'New project'
363
+ if TTY::Which.exist?('gum')
364
+ gum = TTY::Which.which('gum')
365
+ prompt = 'Enter new project name: '
366
+ new_proj_name = `#{gum} input --placeholder "#{prompt}"`.strip
367
+ else
368
+ print 'Enter new project name: '
369
+ new_proj_name = (STDIN.gets || '').strip
370
+ end
371
+ # Create the new project in the file
372
+ NA.insert_project(selected_file, new_proj_name, todo.projects)
373
+ options[:move] = new_proj_name
374
+ else
375
+ options[:move] = selected_project
376
+ end
377
+ when :restore
378
+ options[:restore] = true
379
+ when :archive
380
+ options[:archive] = true
381
+ when :note
382
+ options[:note] = true
383
+ note = [param_value]
384
+ end
385
+ end
386
+ did_direct_update = false
387
+ selected_indices.each do |idx|
388
+ # Rebuild all derived variables from options after menu-driven assignment
389
+ add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
390
+ remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
391
+ remove_tags << 'done' if options[:restore]
392
+ priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
393
+ target_proj = if options[:move]
394
+ options[:move]
395
+ elsif NA.respond_to?(:cwd_is) && NA.cwd_is == :project
396
+ NA.cwd
397
+ end
398
+ note_val = note
399
+ if options[:note] && defined?(param_value) && param_value
400
+ note_val = [param_value]
401
+ 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)
425
+ did_direct_update = true
426
+ end
427
+ if did_direct_update
428
+ next
152
429
  end
153
430
 
154
431
  all_req = options[:tagged].join(' ') !~ /[+!-]/ && !options[:or]
@@ -192,7 +469,7 @@ class App
192
469
  # Require at least one actionable option to be provided
193
470
  actionable = [
194
471
  options[:note],
195
- (options[:priority].to_i if options[:priority]).to_i > 0,
472
+ (options[:priority].to_i if options[:priority]).to_i.positive?,
196
473
  !options[:move].to_s.empty?,
197
474
  !(options[:tag].nil? || options[:tag].empty?),
198
475
  !(options[:remove].nil? || options[:remove].empty?),
data/bin/na CHANGED
@@ -10,11 +10,11 @@ require 'fcntl'
10
10
  require 'tempfile'
11
11
 
12
12
  NA::Benchmark.init
13
- NA::Benchmark.measure('Gem loading') { nil } # Measures time up to this point
13
+ NA::Benchmark.measure('Gem loading') { nil } # Measures time up to this point
14
14
 
15
15
  # Search for XDG compliant config first. Default to ~/.na.rc for compatibility
16
16
  def self.find_config_file
17
- home = ENV['HOME']
17
+ home = Dir.home
18
18
  xdg_config_home = ENV['XDG_CONFIG_HOME'] || File.join(home, '.config')
19
19
 
20
20
  rc_paths = [
@@ -30,7 +30,6 @@ def self.find_config_file
30
30
  existing_path || File.join(xdg_config_home, 'na', 'na.rc')
31
31
  end
32
32
 
33
-
34
33
  # Main application
35
34
  class App
36
35
  extend GLI::App
@@ -84,7 +83,7 @@ class App
84
83
  switch %i[repo-top], default_value: false
85
84
 
86
85
  desc 'Provide a template for new/blank todo files, use initconfig to make permanent'
87
- flag %[template]
86
+ flag %(template)
88
87
 
89
88
  desc 'Use current working directory as [p]roject, [t]ag, or [n]one'
90
89
  arg_name 'TYPE'
@@ -121,7 +120,7 @@ class App
121
120
  NA.include_ext = global[:include_ext]
122
121
  NA.na_tag = global[:na_tag]
123
122
  NA.global_file = global[:file]
124
- NA.cwd = File.basename(ENV['PWD'])
123
+ NA.cwd = File.basename(ENV.fetch('PWD', nil))
125
124
  NA.cwd_is = if global[:cwd_as] =~ /^n/
126
125
  :none
127
126
  else
@@ -146,7 +145,8 @@ class App
146
145
  NA.global_file = taskpaper_file
147
146
  # Add this block to create the file if it doesn't exist
148
147
  unless File.exist?(taskpaper_file)
149
- res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Repository file not found, create #{taskpaper_file}"), default: true)
148
+ res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Repository file not found, create #{taskpaper_file}"),
149
+ default: true)
150
150
  if res
151
151
  NA.create_todo(taskpaper_file, repo_name, template: global[:template])
152
152
  else
@@ -198,7 +198,7 @@ class App
198
198
  end
199
199
 
200
200
  NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
201
- NA.stdin = nil unless NA.stdin && NA.stdin.length.positive?
201
+ NA.stdin = nil unless NA.stdin&.length&.positive?
202
202
 
203
203
  NA.globals = []
204
204
  NA.command_line = []
data/lib/na/action.rb CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  module NA
4
4
  class Action < Hash
5
- attr_reader :file, :project, :parent, :tags, :line
6
-
7
- attr_accessor :action, :note
5
+ attr_reader :file, :project, :tags, :line
6
+ attr_accessor :parent, :action, :note
8
7
 
9
8
  def initialize(file, project, parent, action, idx, note = [])
10
9
  super()
@@ -12,12 +11,20 @@ module NA
12
11
  @file = file
13
12
  @project = project
14
13
  @parent = parent
15
- @action = action.gsub(/\{/, '\\{')
14
+ @action = action.gsub('{', '\\{')
16
15
  @tags = scan_tags
17
16
  @line = idx
18
17
  @note = note
19
18
  end
20
19
 
20
+ # Update the action string and note with priority, tags, and completion status
21
+ #
22
+ # @param priority [Integer] Priority value to set
23
+ # @param finish [Boolean] Mark as finished
24
+ # @param add_tag [Array<String>] Tags to add
25
+ # @param remove_tag [Array<String>] Tags to remove
26
+ # @param note [Array<String>] Notes to set
27
+ # @return [void]
21
28
  def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
22
29
  string = @action.dup
23
30
 
@@ -44,6 +51,9 @@ module NA
44
51
  @note = note unless note.empty?
45
52
  end
46
53
 
54
+ # String representation of the action
55
+ #
56
+ # @return [String]
47
57
  def to_s
48
58
  note = if @note.count.positive?
49
59
  "\n#{@note.join("\n")}"
@@ -53,36 +63,41 @@ module NA
53
63
  "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
54
64
  end
55
65
 
66
+ # Pretty string representation of the action with color formatting
67
+ #
68
+ # @return [String]
56
69
  def to_s_pretty
57
70
  note = if @note.count.positive?
58
71
  "\n#{@note.join("\n")}"
59
72
  else
60
73
  ''
61
74
  end
62
- "#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}#{NA.theme[:bracket]}[#{NA.theme[:project]}#{@project}:#{@parent.join(">")}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}#{NA.theme[:note]}#{note}"
75
+ "{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}"
63
76
  end
64
77
 
78
+ # Inspect the action object
79
+ #
80
+ # @return [String]
65
81
  def inspect
66
82
  <<~EOINSPECT
67
- @file: #{@file}
68
- @project: #{@project}
69
- @parent: #{@parent.join('>')}
70
- @action: #{@action}
71
- @tags: #{@tags}
72
- @note: #{@note}
83
+ @file: #{@file}
84
+ @project: #{@project}
85
+ @parent: #{@parent.join('>')}
86
+ @action: #{@action}
87
+ @tags: #{@tags}
88
+ @note: #{@note}
73
89
  EOINSPECT
74
90
  end
75
91
 
76
- ##
77
- ## Pretty print an action
78
- ##
79
- ## @param extension [String] The file extension
80
- ## @param template [Hash] The template to use for
81
- ## colorization
82
- ## @param regexes [Array] The regexes to
83
- ## highlight (searches)
84
- ## @param notes [Boolean] Include notes
85
- ##
92
+ #
93
+ # Pretty print an action with color and template formatting
94
+ #
95
+ # @param extension [String] File extension
96
+ # @param template [Hash] Color template
97
+ # @param regexes [Array] Regexes to highlight
98
+ # @param notes [Boolean] Include notes
99
+ # @param detect_width [Boolean] Detect terminal width
100
+ # @return [String]
86
101
  def pretty(extension: 'taskpaper', template: {}, regexes: [], notes: false, detect_width: true)
87
102
  NA::Benchmark.measure('Action.pretty') do
88
103
  # Use cached theme instead of loading every time
@@ -98,24 +113,29 @@ module NA
98
113
  # Create the hierarchical parent string (optimized)
99
114
  parents = if needs_parents && @parent.any?
100
115
  parent_parts = @parent.map { |par| "#{template[:parent]}#{par}" }.join(template[:parent_divider])
101
- NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}#{template[:bracket]}]{x} ")
116
+ NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}{x}#{template[:bracket]}]{x} ")
102
117
  else
103
118
  ''
104
119
  end
105
120
 
106
121
  # Create the project string (optimized)
107
122
  project = if needs_project && !@project.empty?
108
- NA::Color.template("#{template[:project]}#{@project}{x} ")
123
+ NA::Color.template("{x}#{template[:project]}#{@project}{x} ")
109
124
  else
110
125
  ''
111
126
  end
112
127
 
113
128
  # Create the source filename string (optimized)
114
129
  filename = if needs_filename
115
- file = @file.sub(%r{^\./}, '').sub(/#{ENV['HOME']}/, '~')
116
- file = file.sub(/\.#{extension}$/, '') unless NA.include_ext
117
- file = file.highlight_filename
118
- NA::Color.template("#{template[:filename]}#{file} {x}")
130
+ path = @file.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~')
131
+ if File.dirname(path) == '.'
132
+ fname = NA.include_ext ? File.basename(path) : File.basename(path, ".#{extension}")
133
+ fname = "./#{fname}" if NA.show_cwd_indicator
134
+ NA::Color.template("#{template[:filename]}#{fname} {x}")
135
+ else
136
+ colored = (NA.include_ext ? path : path.sub(/\.#{extension}$/, '')).highlight_filename
137
+ NA::Color.template("#{template[:filename]}#{colored} {x}")
138
+ end
119
139
  else
120
140
  ''
121
141
  end
@@ -138,44 +158,58 @@ module NA
138
158
  # Cache width calculation
139
159
  width = @cached_width ||= TTY::Screen.columns
140
160
  # Calculate indent more efficiently - avoid repeated template processing
141
- base_template = output_template.gsub(/%action/, '').gsub(/%note/, '')
142
- base_output = base_template.gsub(/%filename/, filename).gsub(/%project/, project).gsub(/%parents?/, parents)
161
+ base_template = output_template.gsub('%action', '').gsub('%note', '')
162
+ base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/,
163
+ parents)
143
164
  indent = NA::Color.uncolor(NA::Color.template(base_output)).length
144
165
  note = NA::Color.template(@note.wrap(width, indent, template[:note]))
145
166
  else
146
- note = NA::Color.template("\n#{@note.map { |l| " #{template[:note]}• #{l}{x}" }.join("\n")}")
167
+ note = NA::Color.template("\n#{@note.map { |l| " {x}#{template[:note]}• #{l}{x}" }.join("\n")}")
147
168
  end
148
169
  else
149
- action += "#{template[:note]}*"
170
+ action += "{x}#{template[:note]}*"
150
171
  end
151
172
  end
152
173
 
153
174
  # Wrap action if needed (optimized)
154
175
  if detect_width && !action.empty?
155
176
  width = @cached_width ||= TTY::Screen.columns
156
- base_template = output_template.gsub(/%action/, '').gsub(/%note/, '')
157
- base_output = base_template.gsub(/%filename/, filename).gsub(/%project/, project).gsub(/%parents?/, parents)
177
+ base_template = output_template.gsub('%action', '').gsub('%note', '')
178
+ base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/, parents)
158
179
  indent = NA::Color.uncolor(NA::Color.template(base_output)).length
159
180
  action = action.wrap(width, indent)
160
181
  end
161
182
 
162
183
  # Replace variables in template string and output colorized (optimized)
163
184
  final_output = output_template.dup
164
- final_output.gsub!(/%filename/, filename)
165
- final_output.gsub!(/%project/, project)
185
+ final_output.gsub!('%filename', filename)
186
+ final_output.gsub!('%project', project)
166
187
  final_output.gsub!(/%parents?/, parents)
167
- final_output.gsub!(/%action/, action.highlight_search(regexes))
168
- final_output.gsub!(/%note/, note)
169
- final_output.gsub!(/\\\{/, '{')
188
+ final_output.gsub!('%action', action.highlight_search(regexes))
189
+ final_output.gsub!('%note', note)
190
+ final_output.gsub!('\\{', '{')
170
191
 
171
192
  NA::Color.template(final_output)
172
193
  end
173
194
  end
174
195
 
196
+ # Check if action tags match any, all, and none criteria
197
+ #
198
+ # @param any [Array] Tags to match any
199
+ # @param all [Array] Tags to match all
200
+ # @param none [Array] Tags to match none
201
+ # @return [Boolean]
175
202
  def tags_match?(any: [], all: [], none: [])
176
203
  tag_matches_any(any) && tag_matches_all(all) && tag_matches_none(none)
177
204
  end
178
205
 
206
+ # Check if action or note matches any, all, and none search criteria
207
+ #
208
+ # @param any [Array] Regexes to match any
209
+ # @param all [Array] Regexes to match all
210
+ # @param none [Array] Regexes to match none
211
+ # @param include_note [Boolean] Include note in search
212
+ # @return [Boolean]
179
213
  def search_match?(any: [], all: [], none: [], include_note: true)
180
214
  search_matches_any(any, include_note: include_note) &&
181
215
  search_matches_all(all, include_note: include_note) &&
@@ -184,6 +218,11 @@ module NA
184
218
 
185
219
  private
186
220
 
221
+ # Check if action and note do not match any regexes
222
+ #
223
+ # @param regexes [Array] Regexes to check
224
+ # @param include_note [Boolean] Include note in search
225
+ # @return [Boolean]
187
226
  def search_matches_none(regexes, include_note: true)
188
227
  regexes.each do |rx|
189
228
  regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
@@ -193,6 +232,11 @@ module NA
193
232
  true
194
233
  end
195
234
 
235
+ # Check if action or note matches any regexes
236
+ #
237
+ # @param regexes [Array] Regexes to check
238
+ # @param include_note [Boolean] Include note in search
239
+ # @return [Boolean]
196
240
  def search_matches_any(regexes, include_note: true)
197
241
  return true if regexes.empty?
198
242
 
@@ -204,6 +248,11 @@ module NA
204
248
  false
205
249
  end
206
250
 
251
+ # Check if action or note matches all regexes
252
+ #
253
+ # @param regexes [Array] Regexes to check
254
+ # @param include_note [Boolean] Include note in search
255
+ # @return [Boolean]
207
256
  def search_matches_all(regexes, include_note: true)
208
257
  regexes.each do |rx|
209
258
  regex = rx.is_a?(Regexp) ? rx : Regexp.new(rx, Regexp::IGNORECASE)
@@ -213,6 +262,10 @@ module NA
213
262
  true
214
263
  end
215
264
 
265
+ # Check if none of the tags match
266
+ #
267
+ # @param tags [Array] Tags to check
268
+ # @return [Boolean]
216
269
  def tag_matches_none(tags)
217
270
  tags.each do |tag|
218
271
  return false if compare_tag(tag)
@@ -220,6 +273,10 @@ module NA
220
273
  true
221
274
  end
222
275
 
276
+ # Check if any of the tags match
277
+ #
278
+ # @param tags [Array] Tags to check
279
+ # @return [Boolean]
223
280
  def tag_matches_any(tags)
224
281
  return true if tags.empty?
225
282
 
@@ -229,6 +286,10 @@ module NA
229
286
  false
230
287
  end
231
288
 
289
+ # Check if all of the tags match
290
+ #
291
+ # @param tags [Array] Tags to check
292
+ # @return [Boolean]
232
293
  def tag_matches_all(tags)
233
294
  tags.each do |tag|
234
295
  return false unless compare_tag(tag)
@@ -236,6 +297,10 @@ module NA
236
297
  true
237
298
  end
238
299
 
300
+ # Compare a tag against the action's tags with optional value comparison
301
+ #
302
+ # @param tag [Hash] Tag criteria
303
+ # @return [Boolean]
239
304
  def compare_tag(tag)
240
305
  tag_regex = tag[:tag].is_a?(Regexp) ? tag[:tag] : Regexp.new(tag[:tag], Regexp::IGNORECASE)
241
306
  keys = @tags.keys.delete_if { |k| k !~ tag_regex }