na 1.2.80 → 1.2.81

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,51 +77,48 @@ 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
120
 
131
- notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1) unless res && res.length.positive?
121
+ notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1) unless res&.length&.positive?
132
122
 
133
123
  res
134
124
  end
@@ -142,11 +132,25 @@ module NA
142
132
  end
143
133
  end
144
134
 
135
+ # Find all projects in a todo file
136
+ #
137
+ # @param target [String] Path to the todo file
138
+ # @return [Array<NA::Project>] List of projects
145
139
  def find_projects(target)
146
140
  todo = NA::Todo.new(require_na: false, file_path: target)
147
141
  todo.projects
148
142
  end
149
143
 
144
+ # Find actions in a todo file matching criteria
145
+ #
146
+ # @param target [String] Path to the todo file
147
+ # @param search [String, nil] Search string
148
+ # @param tagged [String, nil] Tag to filter
149
+ # @param all [Boolean] Return all actions
150
+ # @param done [Boolean] Include done actions
151
+ # @param project [String, nil] Project name
152
+ # @param search_note [Boolean] Search notes
153
+ # @return [Array] Projects and actions
150
154
  def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
151
155
  todo = NA::Todo.new({ search: search,
152
156
  search_note: search_note,
@@ -157,8 +161,9 @@ module NA
157
161
  done: done })
158
162
 
159
163
  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
164
+ NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target,
165
+ ".#{NA.extension}").highlight_filename}")
166
+ return [todo.projects, NA::Actions.new]
162
167
  end
163
168
 
164
169
  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
@@ -166,7 +171,10 @@ module NA
166
171
  options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
167
172
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
168
173
 
169
- NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless res && res.length.positive?
174
+ unless res&.length&.positive?
175
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
176
+ return [todo.projects, NA::Actions.new]
177
+ end
170
178
 
171
179
  selected = NA::Actions.new
172
180
  res.each do |result|
@@ -177,7 +185,13 @@ module NA
177
185
  [todo.projects, selected]
178
186
  end
179
187
 
180
- def insert_project(target, project, projects)
188
+ # Insert a new project into a todo file
189
+ #
190
+ # @param target [String] Path to the todo file
191
+ # @param project [String] Project name
192
+ # @param projects [Array<NA::Project>] Existing projects
193
+ # @return [NA::Project] The new project
194
+ def insert_project(target, project, _projects)
181
195
  path = project.split(%r{[:/]})
182
196
  todo = NA::Todo.new(file_path: target)
183
197
  built = []
@@ -207,11 +221,11 @@ module NA
207
221
  indent += 1
208
222
  end
209
223
 
210
- if new_path.join('') =~ /Archive/i
224
+ if new_path.join =~ /Archive/i
211
225
  line = todo.projects.last&.last_line || 0
212
- content = content.split(/\n/).insert(line, input.join("\n")).join("\n")
226
+ content = content.split("\n").insert(line, input.join("\n")).join("\n")
213
227
  else
214
- split = content.split(/\n/)
228
+ split = content.split("\n")
215
229
  line = todo.projects.first&.line || 0
216
230
  before = split.slice(0, line).join("\n")
217
231
  after = split.slice(line, split.count - 0).join("\n")
@@ -227,8 +241,9 @@ module NA
227
241
  input.push("#{"\t" * indent}#{part.cap_first}:")
228
242
  indent += 1
229
243
  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)
244
+ content = content.split("\n").insert(line, input.join("\n")).join("\n")
245
+ new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1,
246
+ line + input.count - 1)
232
247
  end
233
248
 
234
249
  File.open(target, 'w') do |f|
@@ -238,6 +253,28 @@ module NA
238
253
  new_project
239
254
  end
240
255
 
256
+ # Update actions in a todo file (add, edit, delete, move, etc.)
257
+ #
258
+ # @param target [String] Path to the todo file
259
+ # @param search [String, nil] Search string
260
+ # @param search_note [Boolean] Search notes
261
+ # @param add [Action, nil] Action to add
262
+ # @param add_tag [Array<String>] Tags to add
263
+ # @param all [Boolean] Update all matching actions
264
+ # @param append [Boolean] Append to project
265
+ # @param delete [Boolean] Delete matching actions
266
+ # @param done [Boolean] Mark as done
267
+ # @param edit [Boolean] Edit matching actions
268
+ # @param finish [Boolean] Mark as finished
269
+ # @param note [Array<String>] Notes to add
270
+ # @param overwrite [Boolean] Overwrite notes
271
+ # @param priority [Integer] Priority value
272
+ # @param project [String, nil] Project name
273
+ # @param move [String, nil] Move to project
274
+ # @param remove_tag [Array<String>] Tags to remove
275
+ # @param replace [String, nil] Replacement text
276
+ # @param tagged [String, nil] Tag to filter
277
+ # @return [void]
241
278
  def update_action(target,
242
279
  search,
243
280
  search_note: true,
@@ -257,7 +294,6 @@ module NA
257
294
  remove_tag: [],
258
295
  replace: nil,
259
296
  tagged: nil)
260
-
261
297
  projects = find_projects(target)
262
298
  affected_actions = []
263
299
 
@@ -265,9 +301,11 @@ module NA
265
301
 
266
302
  if move
267
303
  move = move.sub(/:$/, '')
268
- target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(/:/, '.*?:.*?')}/i }.first
304
+ target_proj = projects.select { |pr| pr.project =~ /#{move.gsub(':', '.*?:.*?')}/i }.first
269
305
  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)
306
+ res = NA.yn(
307
+ NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{move}#{NA.theme[:warning]} doesn't exist, add it"), default: true
308
+ )
271
309
  if res
272
310
  target_proj = insert_project(target, move, projects)
273
311
  projects << target_proj
@@ -277,7 +315,7 @@ module NA
277
315
  end
278
316
  end
279
317
 
280
- contents = target.read_file.split(/\n/)
318
+ contents = target.read_file.split("\n")
281
319
 
282
320
  if add.is_a?(Action)
283
321
  add_tag ||= []
@@ -305,7 +343,9 @@ module NA
305
343
  end
306
344
 
307
345
  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)
346
+ res = NA.yn(
347
+ NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{add.project}#{NA.theme[:warning]} doesn't exist, add it"), default: true
348
+ )
309
349
 
310
350
  if res
311
351
  target_proj = insert_project(target, project, projects)
@@ -314,7 +354,10 @@ module NA
314
354
  NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
315
355
  end
316
356
 
317
- NA.notify("#{NA.theme[:error]}Error parsing project #{NA.theme[:filename]}#{target}", exit_code: 1) if target_proj.nil?
357
+ if target_proj.nil?
358
+ NA.notify("#{NA.theme[:error]}Error parsing project #{NA.theme[:filename]}#{target}",
359
+ exit_code: 1)
360
+ end
318
361
 
319
362
  projects = find_projects(target)
320
363
  contents = target.read_file.split("\n")
@@ -352,15 +395,16 @@ module NA
352
395
  notify(add.pretty)
353
396
 
354
397
  # Track affected action and description
355
- changes = ["added"]
356
- changes << "finished" if finish
398
+ changes = ['added']
399
+ changes << 'finished' if finish
357
400
  changes << "priority=#{priority}" if priority.to_i.positive?
358
401
  changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
359
402
  changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
360
- changes << "note updated" unless note.nil? || note.empty?
403
+ changes << 'note updated' unless note.nil? || note.empty?
361
404
  affected_actions << { action: add, desc: changes.join(', ') }
362
405
  else
363
- _, actions = find_actions(target, search, tagged, done: done, all: all, project: project, search_note: search_note)
406
+ _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
407
+ search_note: search_note)
364
408
 
365
409
  return if actions.nil?
366
410
 
@@ -425,15 +469,15 @@ module NA
425
469
 
426
470
  # Track affected action and description
427
471
  changes = []
428
- changes << "finished" if finish
429
- changes << "edited" if edit
472
+ changes << 'finished' if finish
473
+ changes << 'edited' if edit
430
474
  changes << "priority=#{priority}" if priority.to_i.positive?
431
475
  changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
432
476
  changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
433
- changes << "text replaced" if replace
477
+ changes << 'text replaced' if replace
434
478
  changes << "moved to #{target_proj.project}" if target_proj
435
- changes << "note updated" unless note.nil? || note.empty?
436
- changes = ["updated"] if changes.empty?
479
+ changes << 'note updated' unless note.nil? || note.empty?
480
+ changes = ['updated'] if changes.empty?
437
481
  affected_actions << { action: action, desc: changes.join(', ') }
438
482
  end
439
483
  end
@@ -454,23 +498,23 @@ module NA
454
498
  action_color = delete ? NA.theme[:error] : NA.theme[:success]
455
499
  notify(" #{entry[:action].to_s_pretty} — #{action_color}#{entry[:desc]}")
456
500
  end
501
+ elsif add
502
+ notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
457
503
  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
504
+ notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
463
505
  end
464
506
  end
465
507
 
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
- ##
508
+ # Add an action to a todo file
509
+ #
510
+ # @param file [String] Path to the todo file
511
+ # @param project [String] Project name
512
+ # @param action [String] Action text
513
+ # @param note [Array<String>] Notes
514
+ # @param priority [Integer] Priority value
515
+ # @param finish [Boolean] Mark as finished
516
+ # @param append [Boolean] Append to project
517
+ # @return [void]
474
518
  def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
475
519
  parent = project.split(%r{[:/]})
476
520
 
@@ -484,18 +528,21 @@ module NA
484
528
 
485
529
  action = Action.new(file, project, parent, action, nil, note)
486
530
 
487
- update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish, append: append)
531
+ update_action(file, nil, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish,
532
+ append: append)
488
533
  end
489
534
 
535
+ # Build a nested hash representing project hierarchy from actions
536
+ #
537
+ # @param actions [Array<Action>] List of actions
538
+ # @return [Hash] Nested hierarchy
490
539
  def project_hierarchy(actions)
491
540
  parents = { actions: [] }
492
541
  actions.each do |a|
493
542
  parent = a.parent
494
543
  current_parent = parents
495
544
  parent.each do |par|
496
- if !current_parent.key?(par)
497
- current_parent[par] = { actions: [] }
498
- end
545
+ current_parent[par] = { actions: [] } unless current_parent.key?(par)
499
546
  current_parent = current_parent[par]
500
547
  end
501
548
 
@@ -512,14 +559,14 @@ module NA
512
559
  def output_children(children, level = 1)
513
560
  out = []
514
561
  indent = "\t" * level
562
+ return out if children.nil? || children.empty?
563
+
515
564
  children.each do |k, v|
516
565
  if k.to_s =~ /actions/
517
566
  indent += "\t"
518
-
519
- v.each do |a|
567
+ v&.each do |a|
520
568
  item = "#{indent}- #{a.action}"
521
-
522
- unless a.tags.empty?
569
+ unless a.tags.nil? || a.tags.empty?
523
570
  tags = []
524
571
  a.tags.each do |key, val|
525
572
  next if key =~ /^(due|flagged|done)$/
@@ -528,12 +575,9 @@ module NA
528
575
  tag += "-#{val}" unless val.nil? || val.empty?
529
576
  tags.push(tag)
530
577
  end
531
-
532
578
  item += " @tags(#{tags.join(',')})" unless tags.empty?
533
579
  end
534
-
535
580
  item += "\n#{indent}\t#{a.note.join("\n#{indent}\t")}" unless a.note.empty?
536
-
537
581
  out.push(item)
538
582
  end
539
583
  else
@@ -544,26 +588,42 @@ module NA
544
588
  out
545
589
  end
546
590
 
591
+ # Open a file in the specified editor/application
592
+ #
593
+ # @param file [String, nil] Path to the file
594
+ # @param app [String, nil] Application to use
595
+ # @return [void]
547
596
  def edit_file(file: nil, app: nil)
548
597
  os_open(file, app: app) if file && File.exist?(file)
549
598
  end
550
599
 
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)
600
+ # Locate files matching NA.extension up to a given depth
601
+ #
602
+ # @param depth [Integer] The depth at which to search
603
+ # @param include_hidden [Boolean] Whether to include hidden directories/files
604
+ # @return [Array<String>] List of matching file paths
605
+ def find_files(depth: 1, include_hidden: false)
557
606
  NA::Benchmark.measure("find_files (depth=#{depth})") do
558
607
  return [NA.global_file] if NA.global_file
559
608
 
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?('.') }
609
+ # Build a brace-expanded pattern list covering 1..depth levels, e.g.:
610
+ # depth=1 -> "*.ext"
611
+ # depth=3 -> "{*.ext,*/*.ext,*/*/*.ext}"
612
+ ext = NA.extension
613
+ patterns = (1..[depth.to_i, 1].max).map do |d|
614
+ prefix = d > 1 ? ('*/' * (d - 1)) : ''
615
+ "#{prefix}*.#{ext}"
616
+ end
617
+ pattern = patterns.length == 1 ? patterns.first : "{#{patterns.join(',')}}"
618
+
619
+ files = Dir.glob(pattern, File::FNM_DOTMATCH)
620
+ # Exclude hidden directories/files unless explicitly requested
621
+ unless include_hidden
622
+ files.reject! do |f|
623
+ # reject any path segment beginning with '.' (excluding '.' and '..')
624
+ f.split('/').any? { |seg| seg.start_with?('.') && seg !~ /^\.\.?$/ }
625
+ end
626
+ end
567
627
  files.each { |f| save_working_dir(File.expand_path(f)) }
568
628
  files
569
629
  end
@@ -575,15 +635,15 @@ module NA
575
635
  done: false,
576
636
  file_path: nil,
577
637
  negate: false,
638
+ hidden: false,
578
639
  project: nil,
579
640
  query: nil,
580
641
  regex: false,
581
- require_na: true,
582
642
  search: nil,
583
643
  tag: nil
584
644
  }
585
645
  options = defaults.merge(options)
586
- files = find_files(depth: options[:depth])
646
+ files = find_files(depth: options[:depth], include_hidden: options[:hidden])
587
647
 
588
648
  files.delete_if do |file|
589
649
  cmd_options = {
@@ -605,20 +665,13 @@ module NA
605
665
  files
606
666
  end
607
667
 
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
- ##
668
+ # Find a matching path using semi-fuzzy matching.
669
+ # Search tokens can include ! and + to negate or make required.
670
+ #
671
+ # @param search [Array<Hash>] Search tokens to match
672
+ # @param distance [Integer] Allowed distance between characters
673
+ # @param require_last [Boolean] Require regex to match last element of path
674
+ # @return [Array<String>] Array of matching directories/todo files
622
675
  def match_working_dir(search, distance: 1, require_last: true)
623
676
  file = database_path
624
677
  NA.notify("#{NA.theme[:error]}No na database found", exit_code: 1) unless File.exist?(file)
@@ -637,7 +690,9 @@ module NA
637
690
 
638
691
  NA.notify("Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
639
692
  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)
693
+ NA.notify("Negated directory regex: {x}#{negated.map do |t|
694
+ t.dir_to_rx(distance: distance, require_last: false)
695
+ end}", debug: true)
641
696
 
642
697
  if require_last
643
698
  dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
@@ -673,32 +728,30 @@ module NA
673
728
  out
674
729
  end
675
730
 
676
- ##
677
- ## Save a todo file path to the database
678
- ##
679
- ## @param todo_file The todo file path
680
- ##
731
+ # Save a todo file path to the database
732
+ #
733
+ # @param todo_file [String] The todo file path
734
+ # @return [void]
681
735
  def save_working_dir(todo_file)
682
736
  NA::Benchmark.measure('save_working_dir') do
683
737
  file = database_path
684
738
  content = File.exist?(file) ? file.read_file : ''
685
- dirs = content.split(/\n/)
739
+ dirs = content.split("\n")
686
740
  dirs.push(File.expand_path(todo_file))
687
741
  dirs.sort!.uniq!
688
742
  File.open(file, 'w') { |f| f.puts dirs.join("\n") }
689
743
  end
690
744
  end
691
745
 
692
- ##
693
- ## Save a backed-up file to the database
694
- ##
695
- ## @param file The file
696
- ##
746
+ # Save a backed-up file to the database
747
+ #
748
+ # @param file [String] The file
749
+ # @return [void]
697
750
  def save_modified_file(file)
698
751
  db = database_path(file: 'last_modified.txt')
699
752
  file = File.expand_path(file)
700
753
  if File.exist? db
701
- files = IO.read(db).split(/\n/).map(&:strip)
754
+ files = File.read(db).split("\n").map(&:strip)
702
755
  files.delete(file)
703
756
  files << file
704
757
  File.open(db, 'w') { |f| f.puts(files.join("\n")) }
@@ -707,22 +760,20 @@ module NA
707
760
  end
708
761
  end
709
762
 
710
- ##
711
- ## Get the last modified file from the database
712
- ##
713
- ## @param search The search
714
- ##
763
+ # Get the last modified file from the database
764
+ #
765
+ # @param search [String, nil] Optional search string
766
+ # @return [String, nil] Last modified file path
715
767
  def last_modified_file(search: nil)
716
768
  files = backup_files
717
769
  files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
718
770
  files.last
719
771
  end
720
772
 
721
- ##
722
- ## Get last modified file and restore a backup
723
- ##
724
- ## @param search The search
725
- ##
773
+ # Get last modified file and restore a backup
774
+ #
775
+ # @param search [String, nil] Optional search string
776
+ # @return [void]
726
777
  def restore_last_modified_file(search: nil)
727
778
  file = last_modified_file(search: search)
728
779
  if file
@@ -732,22 +783,23 @@ module NA
732
783
  end
733
784
  end
734
785
 
735
- ##
736
- ## Get list of backed up files
737
- ##
738
- ## @return [Array] list of file paths
739
- ##
786
+ # Get list of backed up files
787
+ #
788
+ # @return [Array<String>] List of file paths
740
789
  def backup_files
741
790
  db = database_path(file: 'last_modified.txt')
742
791
  if File.exist?(db)
743
- IO.read(db).strip.split(/\n/).map(&:strip)
792
+ File.read(db).strip.split("\n").map(&:strip)
744
793
  else
745
794
  NA.notify("#{NA.theme[:error]}Backup database not found")
746
- File.open(db, 'w') { |f| f.puts }
795
+ File.open(db, 'w', &:puts)
747
796
  []
748
797
  end
749
798
  end
750
799
 
800
+ # Move deprecated backup files to new backup folder
801
+ #
802
+ # @return [void]
751
803
  def move_deprecated_backups
752
804
  backup_files.each do |file|
753
805
  if File.exist?(old_backup_path(file))
@@ -757,15 +809,18 @@ module NA
757
809
  end
758
810
  end
759
811
 
812
+ # Get the old backup file path for a file
813
+ #
814
+ # @param file [String] The file
815
+ # @return [String] Old backup file path
760
816
  def old_backup_path(file)
761
817
  File.join(File.dirname(file), ".#{File.basename(file)}.bak")
762
818
  end
763
819
 
764
- ##
765
- ## Get the backup file path for a file
766
- ##
767
- ## @param file The file
768
- ##
820
+ # Get the backup file path for a file
821
+ #
822
+ # @param file [String] The file
823
+ # @return [String] Backup file path
769
824
  def backup_path(file)
770
825
  backup_home = File.expand_path('~/.local/share/na/backup')
771
826
  backup = old_backup_path(file)
@@ -777,6 +832,10 @@ module NA
777
832
  backup_target
778
833
  end
779
834
 
835
+ # Remove entries for missing backup files from the database
836
+ #
837
+ # @param file [String, nil] Optional file to filter
838
+ # @return [void]
780
839
  def weed_modified_files(file = nil)
781
840
  files = backup_files
782
841
 
@@ -787,11 +846,10 @@ module NA
787
846
  File.open(database_path(file: 'last_modified.txt'), 'w') { |f| f.puts files.join("\n") }
788
847
  end
789
848
 
790
- ##
791
- ## Restore a file from backup
792
- ##
793
- ## @param file The file
794
- ##
849
+ # Restore a file from backup
850
+ #
851
+ # @param file [String] The file
852
+ # @return [void]
795
853
  def restore_modified_file(file)
796
854
  bak_file = backup_path(file)
797
855
  if File.exist?(bak_file)
@@ -804,11 +862,10 @@ module NA
804
862
  weed_modified_files(file)
805
863
  end
806
864
 
807
- ##
808
- ## Get path to database of known todo files
809
- ##
810
- ## @return [String] File path
811
- ##
865
+ # Get path to database of known todo files
866
+ #
867
+ # @param file [String] The database filename (default: 'tdlist.txt')
868
+ # @return [String] File path
812
869
  def database_path(file: 'tdlist.txt')
813
870
  db_dir = File.expand_path('~/.local/share/na')
814
871
  # Create directory if needed
@@ -816,11 +873,11 @@ module NA
816
873
  File.join(db_dir, file)
817
874
  end
818
875
 
819
- ##
820
- ## Platform-agnostic open command
821
- ##
822
- ## @param file [String] The file to open
823
- ##
876
+ # Platform-agnostic open command
877
+ #
878
+ # @param file [String] The file to open
879
+ # @param app [String, nil] Optional application to use
880
+ # @return [void]
824
881
  def os_open(file, app: nil)
825
882
  os = RbConfig::CONFIG['target_os']
826
883
  case os
@@ -833,9 +890,9 @@ module NA
833
890
  end
834
891
  end
835
892
 
836
- ##
837
- ## Remove entries from cache database that no longer exist
838
- ##
893
+ #
894
+ # Remove entries from cache database that no longer exist
895
+ #
839
896
  def weed_cache_file
840
897
  db_dir = File.expand_path('~/.local/share/na')
841
898
  db_file = 'tdlist.txt'
@@ -863,7 +920,7 @@ module NA
863
920
 
864
921
  projects = find_projects(target)
865
922
  projects.each do |proj|
866
- parts = proj.project.split(/:/)
923
+ parts = proj.project.split(':')
867
924
  output = if paths
868
925
  "{bg}#{parts.join('{bw}/{bg}')}{x}"
869
926
  else
@@ -883,12 +940,10 @@ module NA
883
940
  content = File.exist?(file) ? file.read_file.strip : ''
884
941
  notify("#{NA.theme[:error]}Database empty", exit_code: 1) if content.empty?
885
942
 
886
- content.split(/\n/)
943
+ content.split("\n")
887
944
  end
888
945
 
889
- dirs.map! do |dir|
890
- dir.highlight_filename
891
- end
946
+ dirs.map!(&:highlight_filename)
892
947
 
893
948
  puts NA::Color.template(dirs.join("\n"))
894
949
  end
@@ -896,7 +951,7 @@ module NA
896
951
  def save_search(title, search)
897
952
  file = database_path(file: 'saved_searches.yml')
898
953
  searches = load_searches
899
- title = title.gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
954
+ title = title.gsub(/[^a-zA-Z0-9]/, '_').gsub(/_+/, '_').downcase
900
955
 
901
956
  if searches.key?(title)
902
957
  res = yn('Overwrite existing definition?', default: true)
@@ -909,10 +964,13 @@ module NA
909
964
  NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
910
965
  end
911
966
 
967
+ # Load saved search definitions from YAML file
968
+ #
969
+ # @return [Hash] Hash of saved searches
912
970
  def load_searches
913
971
  file = database_path(file: 'saved_searches.yml')
914
972
  if File.exist?(file)
915
- searches = YAML.safe_load(file.read_file)
973
+ searches = YAML.load(file.read_file)
916
974
  else
917
975
  searches = {
918
976
  'soon' => 'tagged "due<in 2 days,due>yesterday"',
@@ -925,6 +983,10 @@ module NA
925
983
  searches
926
984
  end
927
985
 
986
+ # Delete saved search definitions by name
987
+ #
988
+ # @param strings [Array<String>, String, nil] Names of searches to delete
989
+ # @return [void]
928
990
  def delete_search(strings = nil)
929
991
  NA.notify("#{NA.theme[:error]}Name of search required", exit_code: 1) if strings.nil? || strings.empty?
930
992
 
@@ -933,7 +995,7 @@ module NA
933
995
 
934
996
  strings = [strings] unless strings.is_a? Array
935
997
 
936
- searches = YAML.safe_load(file.read_file)
998
+ searches = YAML.load(file.read_file)
937
999
  keys = searches.keys.delete_if { |k| k !~ /(#{strings.map(&:wildcard_to_rx).join('|')})/ }
938
1000
 
939
1001
  NA.notify("#{NA.theme[:error]}No search named #{strings.join(', ')} found", exit_code: 1) if keys.empty?
@@ -947,9 +1009,14 @@ module NA
947
1009
 
948
1010
  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
949
1011
 
950
- NA.notify("#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0)
1012
+ NA.notify(
1013
+ "#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0
1014
+ )
951
1015
  end
952
1016
 
1017
+ # Edit saved search definitions in the default editor
1018
+ #
1019
+ # @return [void]
953
1020
  def edit_searches
954
1021
  file = database_path(file: 'saved_searches.yml')
955
1022
  searches = load_searches
@@ -963,23 +1030,22 @@ module NA
963
1030
  NA.notify("#{NA.theme[:success]}Opened #{file} in #{editor}", exit_code: 0)
964
1031
  end
965
1032
 
966
- ##
967
- ## Create a backup file
968
- ##
969
- ## @param target [String] The file to back up
970
- ##
1033
+ # Create a backup file
1034
+ #
1035
+ # @param target [String] The file to back up
1036
+ # @return [void]
971
1037
  def backup_file(target)
972
1038
  FileUtils.cp(target, backup_path(target))
973
1039
  save_modified_file(target)
974
1040
  NA.notify("#{NA.theme[:warning]}Backup file created for #{target.highlight_filename}", debug: true)
975
1041
  end
976
1042
 
977
- ##
978
- ## Request terminal input from user, readline style
979
- ##
980
- ## @param options [Hash] The options
981
- ## @param prompt [String] The prompt
982
- ##
1043
+ #
1044
+ # Request terminal input from user, readline style
1045
+ #
1046
+ # @param options [Hash] The options
1047
+ # @param prompt [String] The prompt
1048
+ #
983
1049
  def request_input(options, prompt: 'Enter text')
984
1050
  if $stdin.isatty && TTY::Which.exist?('gum') && (options[:tagged].nil? || options[:tagged].empty?)
985
1051
  opts = [%(--placeholder "#{prompt}"),
@@ -992,18 +1058,18 @@ module NA
992
1058
  end
993
1059
  end
994
1060
 
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
1061
+ #
1062
+ # Generate a menu of options and allow user selection
1063
+ #
1064
+ # @return [String] The selected option
1065
+ #
1066
+ # @param options [Array] The options from which to choose
1067
+ # @param prompt [String] The prompt
1068
+ # @param multiple [Boolean] If true, allow multiple selections
1069
+ # @param sorted [Boolean] If true, sort selections alphanumerically
1070
+ # @param fzf_args [Array] Additional fzf arguments
1071
+ #
1072
+ # @return [String, Array] array if multiple is true
1007
1073
  def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
1008
1074
  return nil unless $stdout.isatty
1009
1075
 
@@ -1031,12 +1097,14 @@ module NA
1031
1097
  reader = TTY::Reader.new
1032
1098
  puts
1033
1099
  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))
1100
+ puts NA::Color.template(format(
1101
+ "#{NA.theme[:prompt]}%<idx> 2d{xw}) #{NA.theme[:filename]}%<action>s{x}\n", idx: i + 1, action: f
1102
+ ))
1035
1103
  end
1036
1104
  result = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")).strip
1037
1105
  if multiple
1038
1106
  mult_res = []
1039
- result = result.gsub(/,/, ' ').gsub(/ +/, ' ').split(/ /)
1107
+ result = result.gsub(',', ' ').gsub(/ +/, ' ').split(/ /)
1040
1108
  result.each do |r|
1041
1109
  mult_res << options[r.to_i - 1] if r.to_i&.positive?
1042
1110
  end
@@ -1046,19 +1114,20 @@ module NA
1046
1114
  end
1047
1115
  end
1048
1116
 
1049
- return false if res&.strip&.size&.zero?
1117
+ return false if res&.strip&.empty?
1118
+
1050
1119
  # 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))
1120
+ multiple ? NA::Color.uncolor(NA::Color.template(res)).split("\n") : NA::Color.uncolor(NA::Color.template(res))
1052
1121
  end
1053
1122
 
1054
1123
  private
1055
1124
 
1056
- ##
1057
- ## macOS open command
1058
- ##
1059
- ## @param file The file
1060
- ## @param app The application
1061
- ##
1125
+ #
1126
+ # macOS open command
1127
+ #
1128
+ # @param file The file
1129
+ # @param app The application
1130
+ #
1062
1131
  def darwin_open(file, app: nil)
1063
1132
  if app
1064
1133
  `open -a "#{app}" #{Shellwords.escape(file)}`
@@ -1067,20 +1136,20 @@ module NA
1067
1136
  end
1068
1137
  end
1069
1138
 
1070
- ##
1071
- ## Windows open command
1072
- ##
1073
- ## @param file The file
1074
- ##
1139
+ #
1140
+ # Windows open command
1141
+ #
1142
+ # @param file The file
1143
+ #
1075
1144
  def win_open(file)
1076
1145
  `start #{Shellwords.escape(file)}`
1077
1146
  end
1078
1147
 
1079
- ##
1080
- ## Linux open command
1081
- ##
1082
- ## @param file The file
1083
- ##
1148
+ #
1149
+ # Linux open command
1150
+ #
1151
+ # @param file The file
1152
+ #
1084
1153
  def linux_open(file)
1085
1154
  if TTY::Which.exist?('xdg-open')
1086
1155
  `xdg-open #{Shellwords.escape(file)}`