na 1.2.79 → 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,21 +588,45 @@ 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)
557
- return [NA.global_file] if NA.global_file
558
-
559
- files = `find . -maxdepth #{depth} -name "*.#{NA.extension}"`.strip.split("\n")
560
- files.each { |f| save_working_dir(File.expand_path(f)) }
561
- files
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)
606
+ NA::Benchmark.measure("find_files (depth=#{depth})") do
607
+ return [NA.global_file] if NA.global_file
608
+
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
627
+ files.each { |f| save_working_dir(File.expand_path(f)) }
628
+ files
629
+ end
562
630
  end
563
631
 
564
632
  def find_files_matching(options = {})
@@ -567,15 +635,15 @@ module NA
567
635
  done: false,
568
636
  file_path: nil,
569
637
  negate: false,
638
+ hidden: false,
570
639
  project: nil,
571
640
  query: nil,
572
641
  regex: false,
573
- require_na: true,
574
642
  search: nil,
575
643
  tag: nil
576
644
  }
577
645
  options = defaults.merge(options)
578
- files = find_files(depth: options[:depth])
646
+ files = find_files(depth: options[:depth], include_hidden: options[:hidden])
579
647
 
580
648
  files.delete_if do |file|
581
649
  cmd_options = {
@@ -597,20 +665,13 @@ module NA
597
665
  files
598
666
  end
599
667
 
600
- ##
601
- ## Find a matching path using semi-fuzzy matching.
602
- ## Search tokens can include ! and + to negate or make
603
- ## required.
604
- ##
605
- ## @param search [Array] search tokens to
606
- ## match
607
- ## @param distance [Integer] allowed distance
608
- ## between characters
609
- ## @param require_last [Boolean] require regex to
610
- ## match last element of path
611
- ##
612
- ## @return [Array] array of matching directories/todo files
613
- ##
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
614
675
  def match_working_dir(search, distance: 1, require_last: true)
615
676
  file = database_path
616
677
  NA.notify("#{NA.theme[:error]}No na database found", exit_code: 1) unless File.exist?(file)
@@ -629,7 +690,9 @@ module NA
629
690
 
630
691
  NA.notify("Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
631
692
  NA.notify("Required directory regex: {x}#{required.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
632
- 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)
633
696
 
634
697
  if require_last
635
698
  dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
@@ -665,30 +728,30 @@ module NA
665
728
  out
666
729
  end
667
730
 
668
- ##
669
- ## Save a todo file path to the database
670
- ##
671
- ## @param todo_file The todo file path
672
- ##
731
+ # Save a todo file path to the database
732
+ #
733
+ # @param todo_file [String] The todo file path
734
+ # @return [void]
673
735
  def save_working_dir(todo_file)
674
- file = database_path
675
- content = File.exist?(file) ? file.read_file : ''
676
- dirs = content.split(/\n/)
677
- dirs.push(File.expand_path(todo_file))
678
- dirs.sort!.uniq!
679
- File.open(file, 'w') { |f| f.puts dirs.join("\n") }
736
+ NA::Benchmark.measure('save_working_dir') do
737
+ file = database_path
738
+ content = File.exist?(file) ? file.read_file : ''
739
+ dirs = content.split("\n")
740
+ dirs.push(File.expand_path(todo_file))
741
+ dirs.sort!.uniq!
742
+ File.open(file, 'w') { |f| f.puts dirs.join("\n") }
743
+ end
680
744
  end
681
745
 
682
- ##
683
- ## Save a backed-up file to the database
684
- ##
685
- ## @param file The file
686
- ##
746
+ # Save a backed-up file to the database
747
+ #
748
+ # @param file [String] The file
749
+ # @return [void]
687
750
  def save_modified_file(file)
688
751
  db = database_path(file: 'last_modified.txt')
689
752
  file = File.expand_path(file)
690
753
  if File.exist? db
691
- files = IO.read(db).split(/\n/).map(&:strip)
754
+ files = File.read(db).split("\n").map(&:strip)
692
755
  files.delete(file)
693
756
  files << file
694
757
  File.open(db, 'w') { |f| f.puts(files.join("\n")) }
@@ -697,22 +760,20 @@ module NA
697
760
  end
698
761
  end
699
762
 
700
- ##
701
- ## Get the last modified file from the database
702
- ##
703
- ## @param search The search
704
- ##
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
705
767
  def last_modified_file(search: nil)
706
768
  files = backup_files
707
769
  files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
708
770
  files.last
709
771
  end
710
772
 
711
- ##
712
- ## Get last modified file and restore a backup
713
- ##
714
- ## @param search The search
715
- ##
773
+ # Get last modified file and restore a backup
774
+ #
775
+ # @param search [String, nil] Optional search string
776
+ # @return [void]
716
777
  def restore_last_modified_file(search: nil)
717
778
  file = last_modified_file(search: search)
718
779
  if file
@@ -722,22 +783,23 @@ module NA
722
783
  end
723
784
  end
724
785
 
725
- ##
726
- ## Get list of backed up files
727
- ##
728
- ## @return [Array] list of file paths
729
- ##
786
+ # Get list of backed up files
787
+ #
788
+ # @return [Array<String>] List of file paths
730
789
  def backup_files
731
790
  db = database_path(file: 'last_modified.txt')
732
791
  if File.exist?(db)
733
- IO.read(db).strip.split(/\n/).map(&:strip)
792
+ File.read(db).strip.split("\n").map(&:strip)
734
793
  else
735
794
  NA.notify("#{NA.theme[:error]}Backup database not found")
736
- File.open(db, 'w') { |f| f.puts }
795
+ File.open(db, 'w', &:puts)
737
796
  []
738
797
  end
739
798
  end
740
799
 
800
+ # Move deprecated backup files to new backup folder
801
+ #
802
+ # @return [void]
741
803
  def move_deprecated_backups
742
804
  backup_files.each do |file|
743
805
  if File.exist?(old_backup_path(file))
@@ -747,15 +809,18 @@ module NA
747
809
  end
748
810
  end
749
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
750
816
  def old_backup_path(file)
751
817
  File.join(File.dirname(file), ".#{File.basename(file)}.bak")
752
818
  end
753
819
 
754
- ##
755
- ## Get the backup file path for a file
756
- ##
757
- ## @param file The file
758
- ##
820
+ # Get the backup file path for a file
821
+ #
822
+ # @param file [String] The file
823
+ # @return [String] Backup file path
759
824
  def backup_path(file)
760
825
  backup_home = File.expand_path('~/.local/share/na/backup')
761
826
  backup = old_backup_path(file)
@@ -767,6 +832,10 @@ module NA
767
832
  backup_target
768
833
  end
769
834
 
835
+ # Remove entries for missing backup files from the database
836
+ #
837
+ # @param file [String, nil] Optional file to filter
838
+ # @return [void]
770
839
  def weed_modified_files(file = nil)
771
840
  files = backup_files
772
841
 
@@ -777,11 +846,10 @@ module NA
777
846
  File.open(database_path(file: 'last_modified.txt'), 'w') { |f| f.puts files.join("\n") }
778
847
  end
779
848
 
780
- ##
781
- ## Restore a file from backup
782
- ##
783
- ## @param file The file
784
- ##
849
+ # Restore a file from backup
850
+ #
851
+ # @param file [String] The file
852
+ # @return [void]
785
853
  def restore_modified_file(file)
786
854
  bak_file = backup_path(file)
787
855
  if File.exist?(bak_file)
@@ -794,11 +862,10 @@ module NA
794
862
  weed_modified_files(file)
795
863
  end
796
864
 
797
- ##
798
- ## Get path to database of known todo files
799
- ##
800
- ## @return [String] File path
801
- ##
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
802
869
  def database_path(file: 'tdlist.txt')
803
870
  db_dir = File.expand_path('~/.local/share/na')
804
871
  # Create directory if needed
@@ -806,11 +873,11 @@ module NA
806
873
  File.join(db_dir, file)
807
874
  end
808
875
 
809
- ##
810
- ## Platform-agnostic open command
811
- ##
812
- ## @param file [String] The file to open
813
- ##
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]
814
881
  def os_open(file, app: nil)
815
882
  os = RbConfig::CONFIG['target_os']
816
883
  case os
@@ -823,9 +890,9 @@ module NA
823
890
  end
824
891
  end
825
892
 
826
- ##
827
- ## Remove entries from cache database that no longer exist
828
- ##
893
+ #
894
+ # Remove entries from cache database that no longer exist
895
+ #
829
896
  def weed_cache_file
830
897
  db_dir = File.expand_path('~/.local/share/na')
831
898
  db_file = 'tdlist.txt'
@@ -853,7 +920,7 @@ module NA
853
920
 
854
921
  projects = find_projects(target)
855
922
  projects.each do |proj|
856
- parts = proj.project.split(/:/)
923
+ parts = proj.project.split(':')
857
924
  output = if paths
858
925
  "{bg}#{parts.join('{bw}/{bg}')}{x}"
859
926
  else
@@ -873,12 +940,10 @@ module NA
873
940
  content = File.exist?(file) ? file.read_file.strip : ''
874
941
  notify("#{NA.theme[:error]}Database empty", exit_code: 1) if content.empty?
875
942
 
876
- content.split(/\n/)
943
+ content.split("\n")
877
944
  end
878
945
 
879
- dirs.map! do |dir|
880
- dir.highlight_filename
881
- end
946
+ dirs.map!(&:highlight_filename)
882
947
 
883
948
  puts NA::Color.template(dirs.join("\n"))
884
949
  end
@@ -886,7 +951,7 @@ module NA
886
951
  def save_search(title, search)
887
952
  file = database_path(file: 'saved_searches.yml')
888
953
  searches = load_searches
889
- title = title.gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
954
+ title = title.gsub(/[^a-zA-Z0-9]/, '_').gsub(/_+/, '_').downcase
890
955
 
891
956
  if searches.key?(title)
892
957
  res = yn('Overwrite existing definition?', default: true)
@@ -899,10 +964,13 @@ module NA
899
964
  NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
900
965
  end
901
966
 
967
+ # Load saved search definitions from YAML file
968
+ #
969
+ # @return [Hash] Hash of saved searches
902
970
  def load_searches
903
971
  file = database_path(file: 'saved_searches.yml')
904
972
  if File.exist?(file)
905
- searches = YAML.safe_load(file.read_file)
973
+ searches = YAML.load(file.read_file)
906
974
  else
907
975
  searches = {
908
976
  'soon' => 'tagged "due<in 2 days,due>yesterday"',
@@ -915,6 +983,10 @@ module NA
915
983
  searches
916
984
  end
917
985
 
986
+ # Delete saved search definitions by name
987
+ #
988
+ # @param strings [Array<String>, String, nil] Names of searches to delete
989
+ # @return [void]
918
990
  def delete_search(strings = nil)
919
991
  NA.notify("#{NA.theme[:error]}Name of search required", exit_code: 1) if strings.nil? || strings.empty?
920
992
 
@@ -923,7 +995,7 @@ module NA
923
995
 
924
996
  strings = [strings] unless strings.is_a? Array
925
997
 
926
- searches = YAML.safe_load(file.read_file)
998
+ searches = YAML.load(file.read_file)
927
999
  keys = searches.keys.delete_if { |k| k !~ /(#{strings.map(&:wildcard_to_rx).join('|')})/ }
928
1000
 
929
1001
  NA.notify("#{NA.theme[:error]}No search named #{strings.join(', ')} found", exit_code: 1) if keys.empty?
@@ -937,9 +1009,14 @@ module NA
937
1009
 
938
1010
  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
939
1011
 
940
- 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
+ )
941
1015
  end
942
1016
 
1017
+ # Edit saved search definitions in the default editor
1018
+ #
1019
+ # @return [void]
943
1020
  def edit_searches
944
1021
  file = database_path(file: 'saved_searches.yml')
945
1022
  searches = load_searches
@@ -953,23 +1030,22 @@ module NA
953
1030
  NA.notify("#{NA.theme[:success]}Opened #{file} in #{editor}", exit_code: 0)
954
1031
  end
955
1032
 
956
- ##
957
- ## Create a backup file
958
- ##
959
- ## @param target [String] The file to back up
960
- ##
1033
+ # Create a backup file
1034
+ #
1035
+ # @param target [String] The file to back up
1036
+ # @return [void]
961
1037
  def backup_file(target)
962
1038
  FileUtils.cp(target, backup_path(target))
963
1039
  save_modified_file(target)
964
1040
  NA.notify("#{NA.theme[:warning]}Backup file created for #{target.highlight_filename}", debug: true)
965
1041
  end
966
1042
 
967
- ##
968
- ## Request terminal input from user, readline style
969
- ##
970
- ## @param options [Hash] The options
971
- ## @param prompt [String] The prompt
972
- ##
1043
+ #
1044
+ # Request terminal input from user, readline style
1045
+ #
1046
+ # @param options [Hash] The options
1047
+ # @param prompt [String] The prompt
1048
+ #
973
1049
  def request_input(options, prompt: 'Enter text')
974
1050
  if $stdin.isatty && TTY::Which.exist?('gum') && (options[:tagged].nil? || options[:tagged].empty?)
975
1051
  opts = [%(--placeholder "#{prompt}"),
@@ -982,18 +1058,18 @@ module NA
982
1058
  end
983
1059
  end
984
1060
 
985
- ##
986
- ## Generate a menu of options and allow user selection
987
- ##
988
- ## @return [String] The selected option
989
- ##
990
- ## @param options [Array] The options from which to choose
991
- ## @param prompt [String] The prompt
992
- ## @param multiple [Boolean] If true, allow multiple selections
993
- ## @param sorted [Boolean] If true, sort selections alphanumerically
994
- ## @param fzf_args [Array] Additional fzf arguments
995
- ##
996
- ## @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
997
1073
  def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
998
1074
  return nil unless $stdout.isatty
999
1075
 
@@ -1021,12 +1097,14 @@ module NA
1021
1097
  reader = TTY::Reader.new
1022
1098
  puts
1023
1099
  options.each.with_index do |f, i|
1024
- 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
+ ))
1025
1103
  end
1026
1104
  result = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")).strip
1027
1105
  if multiple
1028
1106
  mult_res = []
1029
- result = result.gsub(/,/, ' ').gsub(/ +/, ' ').split(/ /)
1107
+ result = result.gsub(',', ' ').gsub(/ +/, ' ').split(/ /)
1030
1108
  result.each do |r|
1031
1109
  mult_res << options[r.to_i - 1] if r.to_i&.positive?
1032
1110
  end
@@ -1036,19 +1114,20 @@ module NA
1036
1114
  end
1037
1115
  end
1038
1116
 
1039
- return false if res&.strip&.size&.zero?
1117
+ return false if res&.strip&.empty?
1118
+
1040
1119
  # pp NA::Color.uncolor(NA::Color.template(res))
1041
- 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))
1042
1121
  end
1043
1122
 
1044
1123
  private
1045
1124
 
1046
- ##
1047
- ## macOS open command
1048
- ##
1049
- ## @param file The file
1050
- ## @param app The application
1051
- ##
1125
+ #
1126
+ # macOS open command
1127
+ #
1128
+ # @param file The file
1129
+ # @param app The application
1130
+ #
1052
1131
  def darwin_open(file, app: nil)
1053
1132
  if app
1054
1133
  `open -a "#{app}" #{Shellwords.escape(file)}`
@@ -1057,20 +1136,20 @@ module NA
1057
1136
  end
1058
1137
  end
1059
1138
 
1060
- ##
1061
- ## Windows open command
1062
- ##
1063
- ## @param file The file
1064
- ##
1139
+ #
1140
+ # Windows open command
1141
+ #
1142
+ # @param file The file
1143
+ #
1065
1144
  def win_open(file)
1066
1145
  `start #{Shellwords.escape(file)}`
1067
1146
  end
1068
1147
 
1069
- ##
1070
- ## Linux open command
1071
- ##
1072
- ## @param file The file
1073
- ##
1148
+ #
1149
+ # Linux open command
1150
+ #
1151
+ # @param file The file
1152
+ #
1074
1153
  def linux_open(file)
1075
1154
  if TTY::Which.exist?('xdg-open')
1076
1155
  `xdg-open #{Shellwords.escape(file)}`