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.
@@ -5,27 +5,20 @@ module NA
5
5
  class << self
6
6
  include NA::Editor
7
7
 
8
- attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file, :cwd_is, :cwd, :stdin
8
+ attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
9
+ :cwd_is, :cwd, :stdin, :show_cwd_indicator
9
10
 
10
11
  def theme
11
12
  @theme ||= NA::Theme.load_theme
12
13
  end
13
14
 
14
- ##
15
- ## Output to STDERR
16
- ##
17
- ## @param msg [String] The message
18
- ## @param exit_code [Number] The exit code, no
19
- ## exit if false
20
- ## @param debug [Boolean] only display message if running :verbose
21
- ##
22
15
  def notify(msg, exit_code: false, debug: false)
23
16
  return if debug && !NA.verbose
24
17
 
25
18
  if debug
26
- $stderr.puts NA::Color.template("{x}#{NA.theme[:debug]}#{msg}{x}")
19
+ warn NA::Color.template("{x}#{NA.theme[:debug]}#{msg}{x}")
27
20
  else
28
- $stderr.puts NA::Color.template("{x}#{msg}{x}")
21
+ warn NA::Color.template("{x}#{msg}{x}")
29
22
  end
30
23
  Process.exit exit_code if exit_code
31
24
  end
@@ -38,16 +31,16 @@ module NA
38
31
  }
39
32
  end
40
33
 
41
- ##
42
- ## Display and read a Yes/No prompt
43
- ##
44
- ## @param prompt [String] The prompt string
45
- ## @param default [Boolean] default value if
46
- ## return is pressed or prompt is
47
- ## skipped
48
- ##
49
- ## @return [Boolean] result
50
- ##
34
+ #
35
+ # Display and read a Yes/No prompt
36
+ #
37
+ # @param prompt [String] The prompt string
38
+ # @param default [Boolean] default value if
39
+ # return is pressed or prompt is
40
+ # skipped
41
+ #
42
+ # @return [Boolean] result
43
+ #
51
44
  def yn(prompt, default: true)
52
45
  return default unless $stdout.isatty
53
46
 
@@ -63,14 +56,14 @@ module NA
63
56
  res.empty? ? default : res =~ /y/i
64
57
  end
65
58
 
66
- ##
67
- ## Helper function to colorize the Y/N prompt
68
- ##
69
- ## @param choices [Array] The choices with
70
- ## default capitalized
71
- ##
72
- ## @return [String] colorized string
73
- ##
59
+ #
60
+ # Helper function to colorize the Y/N prompt
61
+ #
62
+ # @param choices [Array] The choices with
63
+ # default capitalized
64
+ #
65
+ # @return [String] colorized string
66
+ #
74
67
  def color_single_options(choices = %w[y n])
75
68
  out = []
76
69
  choices.each do |choice|
@@ -84,53 +77,55 @@ module NA
84
77
  NA::Color.template("{xg}[#{out.join('/')}{xg}]{x}")
85
78
  end
86
79
 
87
- ##
88
- ## Create a new todo file
89
- ##
90
- ## @param target [String] The target path
91
- ## @param basename [String] The project base name
92
- ##
80
+ #
81
+ # Create a new todo file
82
+ #
83
+ # @param target [String] The target path
84
+ # @param basename [String] The project base name
85
+ #
93
86
  def create_todo(target, basename, template: nil)
94
87
  File.open(target, 'w') do |f|
95
- if template && File.exist?(template)
96
- content = IO.read(template)
97
- else
98
- content = <<~ENDCONTENT
99
- Inbox:
100
- #{basename}:
101
- \tFeature Requests:
102
- \tIdeas:
103
- \tBugs:
104
- Archive:
105
- Search Definitions:
106
- \tTop Priority @search(@priority = 5 and not @done)
107
- \tHigh Priority @search(@priority > 3 and not @done)
108
- \tMaybe @search(@maybe)
109
- \tNext @search(@#{NA.na_tag} and not @done and not project = \"Archive\")
110
- ENDCONTENT
111
- end
88
+ content = if template && File.exist?(template)
89
+ File.read(template)
90
+ else
91
+ <<~ENDCONTENT
92
+ Inbox:
93
+ #{basename}:
94
+ \tFeature Requests:
95
+ \tIdeas:
96
+ \tBugs:
97
+ Archive:
98
+ Search Definitions:
99
+ \tTop Priority @search(@priority = 5 and not @done)
100
+ \tHigh Priority @search(@priority > 3 and not @done)
101
+ \tMaybe @search(@maybe)
102
+ \tNext @search(@#{NA.na_tag} and not @done and not project = "Archive")
103
+ ENDCONTENT
104
+ end
112
105
  f.puts(content)
113
106
  end
114
107
  save_working_dir(target)
115
108
  notify("#{NA.theme[:warning]}Created #{NA.theme[:file]}#{target}")
116
109
  end
117
110
 
118
- ##
119
- ## Select from multiple files
120
- ##
121
- ## @note If `gum` or `fzf` are available, they'll
122
- ## be used (in that order)
123
- ##
124
- ## @param files [Array] The files
125
- ## @param multiple [Boolean] allow multiple selections
126
- ##
127
- ## @return [String, Array] array if multiple
111
+ # Select from multiple files
112
+ #
113
+ # If `gum` or `fzf` are available, they'll be used (in that order).
114
+ #
115
+ # @param files [Array<String>] The files to select from
116
+ # @param multiple [Boolean] Allow multiple selections
117
+ # @return [String, Array<String>] Selected file(s)
128
118
  def select_file(files, multiple: false)
129
119
  res = choose_from(files, prompt: multiple ? 'Select files' : 'Select a file', multiple: multiple)
130
-
131
- notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1) unless res && res.length.positive?
132
-
133
- res
120
+ if res.nil? || res == false || (res.respond_to?(:length) && res.empty?)
121
+ notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1)
122
+ return nil
123
+ end
124
+ if multiple
125
+ res
126
+ else
127
+ res.is_a?(Array) ? res.first : res
128
+ end
134
129
  end
135
130
 
136
131
  def shift_index_after(projects, idx, length = 1)
@@ -142,11 +137,25 @@ module NA
142
137
  end
143
138
  end
144
139
 
140
+ # Find all projects in a todo file
141
+ #
142
+ # @param target [String] Path to the todo file
143
+ # @return [Array<NA::Project>] List of projects
145
144
  def find_projects(target)
146
145
  todo = NA::Todo.new(require_na: false, file_path: target)
147
146
  todo.projects
148
147
  end
149
148
 
149
+ # Find actions in a todo file matching criteria
150
+ #
151
+ # @param target [String] Path to the todo file
152
+ # @param search [String, nil] Search string
153
+ # @param tagged [String, nil] Tag to filter
154
+ # @param all [Boolean] Return all actions
155
+ # @param done [Boolean] Include done actions
156
+ # @param project [String, nil] Project name
157
+ # @param search_note [Boolean] Search notes
158
+ # @return [Array] Projects and actions
150
159
  def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
151
160
  todo = NA::Todo.new({ search: search,
152
161
  search_note: search_note,
@@ -157,8 +166,9 @@ module NA
157
166
  done: done })
158
167
 
159
168
  unless todo.actions.count.positive?
160
- NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target, ".#{NA.extension}").highlight_filename}")
161
- return
169
+ NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target,
170
+ ".#{NA.extension}").highlight_filename}")
171
+ return [todo.projects, NA::Actions.new]
162
172
  end
163
173
 
164
174
  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
@@ -166,7 +176,10 @@ module NA
166
176
  options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
167
177
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
168
178
 
169
- NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless res && res.length.positive?
179
+ unless res&.length&.positive?
180
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
181
+ return [todo.projects, NA::Actions.new]
182
+ end
170
183
 
171
184
  selected = NA::Actions.new
172
185
  res.each do |result|
@@ -177,7 +190,13 @@ module NA
177
190
  [todo.projects, selected]
178
191
  end
179
192
 
180
- def insert_project(target, project, projects)
193
+ # Insert a new project into a todo file
194
+ #
195
+ # @param target [String] Path to the todo file
196
+ # @param project [String] Project name
197
+ # @param projects [Array<NA::Project>] Existing projects
198
+ # @return [NA::Project] The new project
199
+ def insert_project(target, project, _projects)
181
200
  path = project.split(%r{[:/]})
182
201
  todo = NA::Todo.new(file_path: target)
183
202
  built = []
@@ -207,11 +226,11 @@ module NA
207
226
  indent += 1
208
227
  end
209
228
 
210
- if new_path.join('') =~ /Archive/i
229
+ if new_path.join =~ /Archive/i
211
230
  line = todo.projects.last&.last_line || 0
212
- content = content.split(/\n/).insert(line, input.join("\n")).join("\n")
231
+ content = content.split("\n").insert(line, input.join("\n")).join("\n")
213
232
  else
214
- split = content.split(/\n/)
233
+ split = content.split("\n")
215
234
  line = todo.projects.first&.line || 0
216
235
  before = split.slice(0, line).join("\n")
217
236
  after = split.slice(line, split.count - 0).join("\n")
@@ -227,8 +246,9 @@ module NA
227
246
  input.push("#{"\t" * indent}#{part.cap_first}:")
228
247
  indent += 1
229
248
  end
230
- content = content.split(/\n/).insert(line, input.join("\n")).join("\n")
231
- new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1, line + input.count - 1)
249
+ content = content.split("\n").insert(line, input.join("\n")).join("\n")
250
+ new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1,
251
+ line + input.count - 1)
232
252
  end
233
253
 
234
254
  File.open(target, 'w') do |f|
@@ -238,6 +258,28 @@ module NA
238
258
  new_project
239
259
  end
240
260
 
261
+ # Update actions in a todo file (add, edit, delete, move, etc.)
262
+ #
263
+ # @param target [String] Path to the todo file
264
+ # @param search [String, nil] Search string
265
+ # @param search_note [Boolean] Search notes
266
+ # @param add [Action, nil] Action to add
267
+ # @param add_tag [Array<String>] Tags to add
268
+ # @param all [Boolean] Update all matching actions
269
+ # @param append [Boolean] Append to project
270
+ # @param delete [Boolean] Delete matching actions
271
+ # @param done [Boolean] Mark as done
272
+ # @param edit [Boolean] Edit matching actions
273
+ # @param finish [Boolean] Mark as finished
274
+ # @param note [Array<String>] Notes to add
275
+ # @param overwrite [Boolean] Overwrite notes
276
+ # @param priority [Integer] Priority value
277
+ # @param project [String, nil] Project name
278
+ # @param move [String, nil] Move to project
279
+ # @param remove_tag [Array<String>] Tags to remove
280
+ # @param replace [String, nil] Replacement text
281
+ # @param tagged [String, nil] Tag to filter
282
+ # @return [void]
241
283
  def update_action(target,
242
284
  search,
243
285
  search_note: true,
@@ -257,7 +299,6 @@ module NA
257
299
  remove_tag: [],
258
300
  replace: nil,
259
301
  tagged: nil)
260
-
261
302
  projects = find_projects(target)
262
303
  affected_actions = []
263
304
 
@@ -265,9 +306,11 @@ module NA
265
306
 
266
307
  if move
267
308
  move = move.sub(/:$/, '')
268
- target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(/:/, '.*?:.*?')}/i }.first
309
+ target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(':', '.*?:.*?')}/i }.first
269
310
  if target_proj.nil?
270
- res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true)
311
+ res = NA.yn(
312
+ NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true
313
+ )
271
314
  if res
272
315
  target_proj = insert_project(target, move, projects)
273
316
  projects << target_proj
@@ -277,90 +320,64 @@ module NA
277
320
  end
278
321
  end
279
322
 
280
- contents = target.read_file.split(/\n/)
323
+ contents = target.read_file.split("\n")
281
324
 
282
325
  if add.is_a?(Action)
283
326
  add_tag ||= []
284
327
  add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
285
328
 
286
- projects = find_projects(target)
287
-
288
- target_proj = if target_proj
289
- projects.select { |proj| proj.project =~ /^#{target_proj.project}$/i }.first
290
- else
291
- # First try exact full-path match
292
- projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
293
- end
294
-
295
- # If no exact match, try unique suffix match (e.g., :Ideas at end)
296
- if target_proj.nil?
297
- leaf = Regexp.escape(add.parent.join(':'))
298
- suffix_matches = projects.select { |proj| proj.project =~ /(^|:)#{leaf}$/i }
299
- if suffix_matches.count == 1
300
- target_proj = suffix_matches.first
301
- elsif suffix_matches.count > 1 && $stdout.isatty
302
- choice = choose_from(suffix_matches.map(&:project), prompt: 'Select a target project: ', multiple: false)
303
- target_proj = projects.select { |proj| proj.project == choice }.first if choice
304
- end
305
- end
306
-
307
- if target_proj.nil?
308
- res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{add.project}#{NA.theme[:warning]} doesn't exist, add it"), default: true)
309
-
310
- if res
311
- target_proj = insert_project(target, project, projects)
312
- projects << target_proj
313
- else
314
- NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
315
- end
316
-
317
- NA.notify("#{NA.theme[:error]}Error parsing project #{NA.theme[:filename]}#{target}", exit_code: 1) if target_proj.nil?
329
+ # Remove the original action and its notes
330
+ action_line = add.line
331
+ note_lines = add.note.is_a?(Array) ? add.note.count : 0
332
+ contents.slice!(action_line, note_lines + 1)
318
333
 
319
- projects = find_projects(target)
320
- contents = target.read_file.split("\n")
321
- end
334
+ # Prepare updated note
335
+ note = note.to_s.split("\n") unless note.is_a?(Array)
336
+ updated_note = if note.empty?
337
+ add.note
338
+ else
339
+ overwrite ? note : add.note.concat(note)
340
+ end
322
341
 
323
- indent = "\t" * target_proj.indent
324
- note = note.split("\n") unless note.is_a?(Array)
325
- note = if note.empty?
326
- add.note
327
- else
328
- overwrite ? note : add.note.concat(note)
329
- end
330
-
331
- note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"
332
-
333
- if append
334
- this_idx = 0
335
- projects.each_with_index do |proj, idx|
336
- if proj.line == target_proj.line
337
- this_idx = idx
338
- break
339
- end
340
- end
341
- target_line = if this_idx == projects.length - 1
342
- contents.count
342
+ # Prepare indentation
343
+ projects = find_projects(target)
344
+ # If move is set, update add.parent to the target project
345
+ add.parent = target_proj.project.split(':') if move && target_proj
346
+ target_proj = projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/i }.first
347
+ indent = target_proj ? ("\t" * target_proj.indent) : ''
348
+
349
+ # Format note for insertion
350
+ note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
351
+
352
+ # Insert at correct location: if moving, insert at start/end of target project
353
+ if move && target_proj
354
+ insert_line = if append
355
+ # End of project
356
+ target_proj.last_line + 1
343
357
  else
344
- projects[this_idx].last_line + 1
358
+ # Start of project (after project header)
359
+ target_proj.line + 1
345
360
  end
361
+ contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
346
362
  else
347
- target_line = target_proj.line + 1
363
+ # Not moving, update in-place
364
+ contents.insert(action_line, "#{indent}\t- #{add.action}#{note_str}")
348
365
  end
349
366
 
350
- contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
351
-
352
367
  notify(add.pretty)
353
368
 
354
369
  # Track affected action and description
355
- changes = ["added"]
356
- changes << "finished" if finish
370
+ changes = ['updated']
371
+ changes << 'finished' if finish
357
372
  changes << "priority=#{priority}" if priority.to_i.positive?
358
373
  changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
359
374
  changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
360
- changes << "note updated" unless note.nil? || note.empty?
375
+ changes << 'note updated' unless note.nil? || note.empty?
376
+ changes << "moved to #{target_proj.project}" if move && target_proj
361
377
  affected_actions << { action: add, desc: changes.join(', ') }
362
378
  else
363
- _, actions = find_actions(target, search, tagged, done: done, all: all, project: project, search_note: search_note)
379
+ _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
380
+ search_note: search_note)
364
381
 
365
382
  return if actions.nil?
366
383
 
@@ -425,15 +442,15 @@ module NA
425
442
 
426
443
  # Track affected action and description
427
444
  changes = []
428
- changes << "finished" if finish
429
- changes << "edited" if edit
445
+ changes << 'finished' if finish
446
+ changes << 'edited' if edit
430
447
  changes << "priority=#{priority}" if priority.to_i.positive?
431
448
  changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
432
449
  changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
433
- changes << "text replaced" if replace
450
+ changes << 'text replaced' if replace
434
451
  changes << "moved to #{target_proj.project}" if target_proj
435
- changes << "note updated" unless note.nil? || note.empty?
436
- changes = ["updated"] if changes.empty?
452
+ changes << 'note updated' unless note.nil? || note.empty?
453
+ changes = ['updated'] if changes.empty?
437
454
  affected_actions << { action: action, desc: changes.join(', ') }
438
455
  end
439
456
  end
@@ -454,23 +471,23 @@ module NA
454
471
  action_color = delete ? NA.theme[:error] : NA.theme[:success]
455
472
  notify(" #{entry[:action].to_s_pretty} — #{action_color}#{entry[:desc]}")
456
473
  end
474
+ elsif add
475
+ notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
457
476
  else
458
- if add
459
- notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
460
- else
461
- notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
462
- end
477
+ notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
463
478
  end
464
479
  end
465
480
 
466
- ##
467
- ## Add an action to a todo file
468
- ##
469
- ## @param file [String] The target file
470
- ## @param project [String] The project name
471
- ## @param action [String] The action
472
- ## @param note [String] The note
473
- ##
481
+ # Add an action to a todo file
482
+ #
483
+ # @param file [String] Path to the todo file
484
+ # @param project [String] Project name
485
+ # @param action [String] Action text
486
+ # @param note [Array<String>] Notes
487
+ # @param priority [Integer] Priority value
488
+ # @param finish [Boolean] Mark as finished
489
+ # @param append [Boolean] Append to project
490
+ # @return [void]
474
491
  def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
475
492
  parent = project.split(%r{[:/]})
476
493
 
@@ -484,18 +501,21 @@ module NA
484
501
 
485
502
  action = Action.new(file, project, parent, action, nil, note)
486
503
 
487
- update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish, append: append)
504
+ update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish,
505
+ append: append)
488
506
  end
489
507
 
508
+ # Build a nested hash representing project hierarchy from actions
509
+ #
510
+ # @param actions [Array<Action>] List of actions
511
+ # @return [Hash] Nested hierarchy
490
512
  def project_hierarchy(actions)
491
513
  parents = { actions: [] }
492
514
  actions.each do |a|
493
515
  parent = a.parent
494
516
  current_parent = parents
495
517
  parent.each do |par|
496
- if !current_parent.key?(par)
497
- current_parent[par] = { actions: [] }
498
- end
518
+ current_parent[par] = { actions: [] } unless current_parent.key?(par)
499
519
  current_parent = current_parent[par]
500
520
  end
501
521
 
@@ -512,14 +532,14 @@ module NA
512
532
  def output_children(children, level = 1)
513
533
  out = []
514
534
  indent = "\t" * level
535
+ return out if children.nil? || children.empty?
536
+
515
537
  children.each do |k, v|
516
538
  if k.to_s =~ /actions/
517
539
  indent += "\t"
518
-
519
- v.each do |a|
540
+ v&.each do |a|
520
541
  item = "#{indent}- #{a.action}"
521
-
522
- unless a.tags.empty?
542
+ unless a.tags.nil? || a.tags.empty?
523
543
  tags = []
524
544
  a.tags.each do |key, val|
525
545
  next if key =~ /^(due|flagged|done)$/
@@ -528,12 +548,9 @@ module NA
528
548
  tag += "-#{val}" unless val.nil? || val.empty?
529
549
  tags.push(tag)
530
550
  end
531
-
532
551
  item += " @tags(#{tags.join(',')})" unless tags.empty?
533
552
  end
534
-
535
553
  item += "\n#{indent}\t#{a.note.join("\n#{indent}\t")}" unless a.note.empty?
536
-
537
554
  out.push(item)
538
555
  end
539
556
  else
@@ -544,26 +561,42 @@ module NA
544
561
  out
545
562
  end
546
563
 
564
+ # Open a file in the specified editor/application
565
+ #
566
+ # @param file [String, nil] Path to the file
567
+ # @param app [String, nil] Application to use
568
+ # @return [void]
547
569
  def edit_file(file: nil, app: nil)
548
570
  os_open(file, app: app) if file && File.exist?(file)
549
571
  end
550
572
 
551
- ##
552
- ## Use the *nix `find` command to locate files matching NA.extension
553
- ##
554
- ## @param depth [Number] The depth at which to search
555
- ##
556
- def find_files(depth: 1)
573
+ # Locate files matching NA.extension up to a given depth
574
+ #
575
+ # @param depth [Integer] The depth at which to search
576
+ # @param include_hidden [Boolean] Whether to include hidden directories/files
577
+ # @return [Array<String>] List of matching file paths
578
+ def find_files(depth: 1, include_hidden: false)
557
579
  NA::Benchmark.measure("find_files (depth=#{depth})") do
558
580
  return [NA.global_file] if NA.global_file
559
581
 
560
- pattern = if depth == 1
561
- "*.#{NA.extension}"
562
- else
563
- "{#{'*,' * (depth - 1)}*}.#{NA.extension}"
564
- end
565
-
566
- files = Dir.glob(pattern, File::FNM_DOTMATCH).reject { |f| f.start_with?('.') }
582
+ # Build a brace-expanded pattern list covering 1..depth levels, e.g.:
583
+ # depth=1 -> "*.ext"
584
+ # depth=3 -> "{*.ext,*/*.ext,*/*/*.ext}"
585
+ ext = NA.extension
586
+ patterns = (1..[depth.to_i, 1].max).map do |d|
587
+ prefix = d > 1 ? ('*/' * (d - 1)) : ''
588
+ "#{prefix}*.#{ext}"
589
+ end
590
+ pattern = patterns.length == 1 ? patterns.first : "{#{patterns.join(',')}}"
591
+
592
+ files = Dir.glob(pattern, File::FNM_DOTMATCH)
593
+ # Exclude hidden directories/files unless explicitly requested
594
+ unless include_hidden
595
+ files.reject! do |f|
596
+ # reject any path segment beginning with '.' (excluding '.' and '..')
597
+ f.split('/').any? { |seg| seg.start_with?('.') && seg !~ /^\.\.?$/ }
598
+ end
599
+ end
567
600
  files.each { |f| save_working_dir(File.expand_path(f)) }
568
601
  files
569
602
  end
@@ -575,15 +608,15 @@ module NA
575
608
  done: false,
576
609
  file_path: nil,
577
610
  negate: false,
611
+ hidden: false,
578
612
  project: nil,
579
613
  query: nil,
580
614
  regex: false,
581
- require_na: true,
582
615
  search: nil,
583
616
  tag: nil
584
617
  }
585
618
  options = defaults.merge(options)
586
- files = find_files(depth: options[:depth])
619
+ files = find_files(depth: options[:depth], include_hidden: options[:hidden])
587
620
 
588
621
  files.delete_if do |file|
589
622
  cmd_options = {
@@ -605,20 +638,13 @@ module NA
605
638
  files
606
639
  end
607
640
 
608
- ##
609
- ## Find a matching path using semi-fuzzy matching.
610
- ## Search tokens can include ! and + to negate or make
611
- ## required.
612
- ##
613
- ## @param search [Array] search tokens to
614
- ## match
615
- ## @param distance [Integer] allowed distance
616
- ## between characters
617
- ## @param require_last [Boolean] require regex to
618
- ## match last element of path
619
- ##
620
- ## @return [Array] array of matching directories/todo files
621
- ##
641
+ # Find a matching path using semi-fuzzy matching.
642
+ # Search tokens can include ! and + to negate or make required.
643
+ #
644
+ # @param search [Array<Hash>] Search tokens to match
645
+ # @param distance [Integer] Allowed distance between characters
646
+ # @param require_last [Boolean] Require regex to match last element of path
647
+ # @return [Array<String>] Array of matching directories/todo files
622
648
  def match_working_dir(search, distance: 1, require_last: true)
623
649
  file = database_path
624
650
  NA.notify("#{NA.theme[:error]}No na database found", exit_code: 1) unless File.exist?(file)
@@ -637,7 +663,9 @@ module NA
637
663
 
638
664
  NA.notify("Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
639
665
  NA.notify("Required directory regex: {x}#{required.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
640
- NA.notify("Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: distance, require_last: false) }}", debug: true)
666
+ NA.notify("Negated directory regex: {x}#{negated.map do |t|
667
+ t.dir_to_rx(distance: distance, require_last: false)
668
+ end}", debug: true)
641
669
 
642
670
  if require_last
643
671
  dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
@@ -673,32 +701,30 @@ module NA
673
701
  out
674
702
  end
675
703
 
676
- ##
677
- ## Save a todo file path to the database
678
- ##
679
- ## @param todo_file The todo file path
680
- ##
704
+ # Save a todo file path to the database
705
+ #
706
+ # @param todo_file [String] The todo file path
707
+ # @return [void]
681
708
  def save_working_dir(todo_file)
682
709
  NA::Benchmark.measure('save_working_dir') do
683
710
  file = database_path
684
711
  content = File.exist?(file) ? file.read_file : ''
685
- dirs = content.split(/\n/)
712
+ dirs = content.split("\n")
686
713
  dirs.push(File.expand_path(todo_file))
687
714
  dirs.sort!.uniq!
688
715
  File.open(file, 'w') { |f| f.puts dirs.join("\n") }
689
716
  end
690
717
  end
691
718
 
692
- ##
693
- ## Save a backed-up file to the database
694
- ##
695
- ## @param file The file
696
- ##
719
+ # Save a backed-up file to the database
720
+ #
721
+ # @param file [String] The file
722
+ # @return [void]
697
723
  def save_modified_file(file)
698
724
  db = database_path(file: 'last_modified.txt')
699
725
  file = File.expand_path(file)
700
726
  if File.exist? db
701
- files = IO.read(db).split(/\n/).map(&:strip)
727
+ files = File.read(db).split("\n").map(&:strip)
702
728
  files.delete(file)
703
729
  files << file
704
730
  File.open(db, 'w') { |f| f.puts(files.join("\n")) }
@@ -707,22 +733,20 @@ module NA
707
733
  end
708
734
  end
709
735
 
710
- ##
711
- ## Get the last modified file from the database
712
- ##
713
- ## @param search The search
714
- ##
736
+ # Get the last modified file from the database
737
+ #
738
+ # @param search [String, nil] Optional search string
739
+ # @return [String, nil] Last modified file path
715
740
  def last_modified_file(search: nil)
716
741
  files = backup_files
717
742
  files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
718
743
  files.last
719
744
  end
720
745
 
721
- ##
722
- ## Get last modified file and restore a backup
723
- ##
724
- ## @param search The search
725
- ##
746
+ # Get last modified file and restore a backup
747
+ #
748
+ # @param search [String, nil] Optional search string
749
+ # @return [void]
726
750
  def restore_last_modified_file(search: nil)
727
751
  file = last_modified_file(search: search)
728
752
  if file
@@ -732,22 +756,23 @@ module NA
732
756
  end
733
757
  end
734
758
 
735
- ##
736
- ## Get list of backed up files
737
- ##
738
- ## @return [Array] list of file paths
739
- ##
759
+ # Get list of backed up files
760
+ #
761
+ # @return [Array<String>] List of file paths
740
762
  def backup_files
741
763
  db = database_path(file: 'last_modified.txt')
742
764
  if File.exist?(db)
743
- IO.read(db).strip.split(/\n/).map(&:strip)
765
+ File.read(db).strip.split("\n").map(&:strip)
744
766
  else
745
767
  NA.notify("#{NA.theme[:error]}Backup database not found")
746
- File.open(db, 'w') { |f| f.puts }
768
+ File.open(db, 'w', &:puts)
747
769
  []
748
770
  end
749
771
  end
750
772
 
773
+ # Move deprecated backup files to new backup folder
774
+ #
775
+ # @return [void]
751
776
  def move_deprecated_backups
752
777
  backup_files.each do |file|
753
778
  if File.exist?(old_backup_path(file))
@@ -757,15 +782,18 @@ module NA
757
782
  end
758
783
  end
759
784
 
785
+ # Get the old backup file path for a file
786
+ #
787
+ # @param file [String] The file
788
+ # @return [String] Old backup file path
760
789
  def old_backup_path(file)
761
790
  File.join(File.dirname(file), ".#{File.basename(file)}.bak")
762
791
  end
763
792
 
764
- ##
765
- ## Get the backup file path for a file
766
- ##
767
- ## @param file The file
768
- ##
793
+ # Get the backup file path for a file
794
+ #
795
+ # @param file [String] The file
796
+ # @return [String] Backup file path
769
797
  def backup_path(file)
770
798
  backup_home = File.expand_path('~/.local/share/na/backup')
771
799
  backup = old_backup_path(file)
@@ -777,6 +805,10 @@ module NA
777
805
  backup_target
778
806
  end
779
807
 
808
+ # Remove entries for missing backup files from the database
809
+ #
810
+ # @param file [String, nil] Optional file to filter
811
+ # @return [void]
780
812
  def weed_modified_files(file = nil)
781
813
  files = backup_files
782
814
 
@@ -787,11 +819,10 @@ module NA
787
819
  File.open(database_path(file: 'last_modified.txt'), 'w') { |f| f.puts files.join("\n") }
788
820
  end
789
821
 
790
- ##
791
- ## Restore a file from backup
792
- ##
793
- ## @param file The file
794
- ##
822
+ # Restore a file from backup
823
+ #
824
+ # @param file [String] The file
825
+ # @return [void]
795
826
  def restore_modified_file(file)
796
827
  bak_file = backup_path(file)
797
828
  if File.exist?(bak_file)
@@ -804,11 +835,10 @@ module NA
804
835
  weed_modified_files(file)
805
836
  end
806
837
 
807
- ##
808
- ## Get path to database of known todo files
809
- ##
810
- ## @return [String] File path
811
- ##
838
+ # Get path to database of known todo files
839
+ #
840
+ # @param file [String] The database filename (default: 'tdlist.txt')
841
+ # @return [String] File path
812
842
  def database_path(file: 'tdlist.txt')
813
843
  db_dir = File.expand_path('~/.local/share/na')
814
844
  # Create directory if needed
@@ -816,11 +846,11 @@ module NA
816
846
  File.join(db_dir, file)
817
847
  end
818
848
 
819
- ##
820
- ## Platform-agnostic open command
821
- ##
822
- ## @param file [String] The file to open
823
- ##
849
+ # Platform-agnostic open command
850
+ #
851
+ # @param file [String] The file to open
852
+ # @param app [String, nil] Optional application to use
853
+ # @return [void]
824
854
  def os_open(file, app: nil)
825
855
  os = RbConfig::CONFIG['target_os']
826
856
  case os
@@ -833,9 +863,9 @@ module NA
833
863
  end
834
864
  end
835
865
 
836
- ##
837
- ## Remove entries from cache database that no longer exist
838
- ##
866
+ #
867
+ # Remove entries from cache database that no longer exist
868
+ #
839
869
  def weed_cache_file
840
870
  db_dir = File.expand_path('~/.local/share/na')
841
871
  db_file = 'tdlist.txt'
@@ -863,7 +893,7 @@ module NA
863
893
 
864
894
  projects = find_projects(target)
865
895
  projects.each do |proj|
866
- parts = proj.project.split(/:/)
896
+ parts = proj.project.split(':')
867
897
  output = if paths
868
898
  "{bg}#{parts.join('{bw}/{bg}')}{x}"
869
899
  else
@@ -883,12 +913,10 @@ module NA
883
913
  content = File.exist?(file) ? file.read_file.strip : ''
884
914
  notify("#{NA.theme[:error]}Database empty", exit_code: 1) if content.empty?
885
915
 
886
- content.split(/\n/)
916
+ content.split("\n")
887
917
  end
888
918
 
889
- dirs.map! do |dir|
890
- dir.highlight_filename
891
- end
919
+ dirs.map!(&:highlight_filename)
892
920
 
893
921
  puts NA::Color.template(dirs.join("\n"))
894
922
  end
@@ -896,7 +924,7 @@ module NA
896
924
  def save_search(title, search)
897
925
  file = database_path(file: 'saved_searches.yml')
898
926
  searches = load_searches
899
- title = title.gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
927
+ title = title.gsub(/[^a-zA-Z0-9]/, '_').gsub(/_+/, '_').downcase
900
928
 
901
929
  if searches.key?(title)
902
930
  res = yn('Overwrite existing definition?', default: true)
@@ -909,10 +937,13 @@ module NA
909
937
  NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
910
938
  end
911
939
 
940
+ # Load saved search definitions from YAML file
941
+ #
942
+ # @return [Hash] Hash of saved searches
912
943
  def load_searches
913
944
  file = database_path(file: 'saved_searches.yml')
914
945
  if File.exist?(file)
915
- searches = YAML.safe_load(file.read_file)
946
+ searches = YAML.load(file.read_file)
916
947
  else
917
948
  searches = {
918
949
  'soon' => 'tagged "due<in 2 days,due>yesterday"',
@@ -925,6 +956,10 @@ module NA
925
956
  searches
926
957
  end
927
958
 
959
+ # Delete saved search definitions by name
960
+ #
961
+ # @param strings [Array<String>, String, nil] Names of searches to delete
962
+ # @return [void]
928
963
  def delete_search(strings = nil)
929
964
  NA.notify("#{NA.theme[:error]}Name of search required", exit_code: 1) if strings.nil? || strings.empty?
930
965
 
@@ -933,7 +968,7 @@ module NA
933
968
 
934
969
  strings = [strings] unless strings.is_a? Array
935
970
 
936
- searches = YAML.safe_load(file.read_file)
971
+ searches = YAML.load(file.read_file)
937
972
  keys = searches.keys.delete_if { |k| k !~ /(#{strings.map(&:wildcard_to_rx).join('|')})/ }
938
973
 
939
974
  NA.notify("#{NA.theme[:error]}No search named #{strings.join(', ')} found", exit_code: 1) if keys.empty?
@@ -947,9 +982,14 @@ module NA
947
982
 
948
983
  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
949
984
 
950
- NA.notify("#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0)
985
+ NA.notify(
986
+ "#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0
987
+ )
951
988
  end
952
989
 
990
+ # Edit saved search definitions in the default editor
991
+ #
992
+ # @return [void]
953
993
  def edit_searches
954
994
  file = database_path(file: 'saved_searches.yml')
955
995
  searches = load_searches
@@ -963,23 +1003,22 @@ module NA
963
1003
  NA.notify("#{NA.theme[:success]}Opened #{file} in #{editor}", exit_code: 0)
964
1004
  end
965
1005
 
966
- ##
967
- ## Create a backup file
968
- ##
969
- ## @param target [String] The file to back up
970
- ##
1006
+ # Create a backup file
1007
+ #
1008
+ # @param target [String] The file to back up
1009
+ # @return [void]
971
1010
  def backup_file(target)
972
1011
  FileUtils.cp(target, backup_path(target))
973
1012
  save_modified_file(target)
974
1013
  NA.notify("#{NA.theme[:warning]}Backup file created for #{target.highlight_filename}", debug: true)
975
1014
  end
976
1015
 
977
- ##
978
- ## Request terminal input from user, readline style
979
- ##
980
- ## @param options [Hash] The options
981
- ## @param prompt [String] The prompt
982
- ##
1016
+ #
1017
+ # Request terminal input from user, readline style
1018
+ #
1019
+ # @param options [Hash] The options
1020
+ # @param prompt [String] The prompt
1021
+ #
983
1022
  def request_input(options, prompt: 'Enter text')
984
1023
  if $stdin.isatty && TTY::Which.exist?('gum') && (options[:tagged].nil? || options[:tagged].empty?)
985
1024
  opts = [%(--placeholder "#{prompt}"),
@@ -992,18 +1031,18 @@ module NA
992
1031
  end
993
1032
  end
994
1033
 
995
- ##
996
- ## Generate a menu of options and allow user selection
997
- ##
998
- ## @return [String] The selected option
999
- ##
1000
- ## @param options [Array] The options from which to choose
1001
- ## @param prompt [String] The prompt
1002
- ## @param multiple [Boolean] If true, allow multiple selections
1003
- ## @param sorted [Boolean] If true, sort selections alphanumerically
1004
- ## @param fzf_args [Array] Additional fzf arguments
1005
- ##
1006
- ## @return [String, Array] array if multiple is true
1034
+ #
1035
+ # Generate a menu of options and allow user selection
1036
+ #
1037
+ # @return [String] The selected option
1038
+ #
1039
+ # @param options [Array] The options from which to choose
1040
+ # @param prompt [String] The prompt
1041
+ # @param multiple [Boolean] If true, allow multiple selections
1042
+ # @param sorted [Boolean] If true, sort selections alphanumerically
1043
+ # @param fzf_args [Array] Additional fzf arguments
1044
+ #
1045
+ # @return [String, Array] array if multiple is true
1007
1046
  def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
1008
1047
  return nil unless $stdout.isatty
1009
1048
 
@@ -1031,12 +1070,14 @@ module NA
1031
1070
  reader = TTY::Reader.new
1032
1071
  puts
1033
1072
  options.each.with_index do |f, i|
1034
- puts NA::Color.template(format("#{NA.theme[:prompt]}%<idx> 2d{xw}) #{NA.theme[:filename]}%<action>s{x}\n", idx: i + 1, action: f))
1073
+ puts NA::Color.template(format(
1074
+ "#{NA.theme[:prompt]}%<idx> 2d{xw}) #{NA.theme[:filename]}%<action>s{x}\n", idx: i + 1, action: f
1075
+ ))
1035
1076
  end
1036
1077
  result = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")).strip
1037
1078
  if multiple
1038
1079
  mult_res = []
1039
- result = result.gsub(/,/, ' ').gsub(/ +/, ' ').split(/ /)
1080
+ result = result.gsub(',', ' ').gsub(/ +/, ' ').split(/ /)
1040
1081
  result.each do |r|
1041
1082
  mult_res << options[r.to_i - 1] if r.to_i&.positive?
1042
1083
  end
@@ -1046,19 +1087,20 @@ module NA
1046
1087
  end
1047
1088
  end
1048
1089
 
1049
- return false if res&.strip&.size&.zero?
1090
+ return false if res&.strip&.empty?
1091
+
1050
1092
  # pp NA::Color.uncolor(NA::Color.template(res))
1051
- multiple ? NA::Color.uncolor(NA::Color.template(res)).split(/\n/) : NA::Color.uncolor(NA::Color.template(res))
1093
+ multiple ? NA::Color.uncolor(NA::Color.template(res)).split("\n") : NA::Color.uncolor(NA::Color.template(res))
1052
1094
  end
1053
1095
 
1054
1096
  private
1055
1097
 
1056
- ##
1057
- ## macOS open command
1058
- ##
1059
- ## @param file The file
1060
- ## @param app The application
1061
- ##
1098
+ #
1099
+ # macOS open command
1100
+ #
1101
+ # @param file The file
1102
+ # @param app The application
1103
+ #
1062
1104
  def darwin_open(file, app: nil)
1063
1105
  if app
1064
1106
  `open -a "#{app}" #{Shellwords.escape(file)}`
@@ -1067,20 +1109,20 @@ module NA
1067
1109
  end
1068
1110
  end
1069
1111
 
1070
- ##
1071
- ## Windows open command
1072
- ##
1073
- ## @param file The file
1074
- ##
1112
+ #
1113
+ # Windows open command
1114
+ #
1115
+ # @param file The file
1116
+ #
1075
1117
  def win_open(file)
1076
1118
  `start #{Shellwords.escape(file)}`
1077
1119
  end
1078
1120
 
1079
- ##
1080
- ## Linux open command
1081
- ##
1082
- ## @param file The file
1083
- ##
1121
+ #
1122
+ # Linux open command
1123
+ #
1124
+ # @param file The file
1125
+ #
1084
1126
  def linux_open(file)
1085
1127
  if TTY::Which.exist?('xdg-open')
1086
1128
  `xdg-open #{Shellwords.escape(file)}`