na 1.2.35 → 1.2.38

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,8 +3,14 @@
3
3
  # Next Action methods
4
4
  module NA
5
5
  class << self
6
+ include NA::Editor
7
+
6
8
  attr_accessor :verbose, :extension, :na_tag, :command_line, :command, :globals, :global_file, :cwd_is, :cwd, :stdin
7
9
 
10
+ def theme
11
+ @theme ||= NA::Theme.load_theme
12
+ end
13
+
8
14
  ##
9
15
  ## Output to STDERR
10
16
  ##
@@ -16,7 +22,11 @@ module NA
16
22
  def notify(msg, exit_code: false, debug: false)
17
23
  return if debug && !NA.verbose
18
24
 
19
- $stderr.puts NA::Color.template("{x}#{msg}{x}")
25
+ if debug
26
+ $stderr.puts NA::Color.template("{x}#{NA.theme[:debug]}#{msg}{x}")
27
+ else
28
+ $stderr.puts NA::Color.template("{x}#{msg}{x}")
29
+ end
20
30
  Process.exit exit_code if exit_code
21
31
  end
22
32
 
@@ -45,125 +55,6 @@ module NA
45
55
  res.empty? ? default : res =~ /y/i
46
56
  end
47
57
 
48
- def default_editor
49
- editor ||= ENV['NA_EDITOR'] || ENV['GIT_EDITOR'] || ENV['EDITOR']
50
-
51
- if editor.good? && TTY::Which.exist?(editor)
52
- return editor
53
- end
54
-
55
- notify('No EDITOR environment variable, testing available editors', debug: true)
56
- editors = %w[vim vi code subl mate mvim nano emacs]
57
- editors.each do |ed|
58
- try = TTY::Which.which(ed)
59
- if try
60
- notify("Using editor #{try}", debug: true)
61
- return try
62
- end
63
- end
64
-
65
- notify('{br}No editor found{x}', exit_code: 5)
66
-
67
- nil
68
- end
69
-
70
- def editor_with_args
71
- args_for_editor(default_editor)
72
- end
73
-
74
- def args_for_editor(editor)
75
- return editor if editor =~ /-\S/
76
-
77
- args = case editor
78
- when /^(subl|code|mate)$/
79
- ['-w']
80
- when /^(vim|mvim)$/
81
- ['-f']
82
- else
83
- []
84
- end
85
- "#{editor} #{args.join(' ')}"
86
- end
87
-
88
- ##
89
- ## Create a process for an editor and wait for the file handle to return
90
- ##
91
- ## @param input [String] Text input for editor
92
- ##
93
- def fork_editor(input = '', message: :default)
94
- # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
95
-
96
- notify('{br}No EDITOR variable defined in environment{x}', exit_code: 5) if default_editor.nil?
97
-
98
- tmpfile = Tempfile.new(['na_temp', '.na'])
99
-
100
- File.open(tmpfile.path, 'w+') do |f|
101
- f.puts input
102
- unless message.nil?
103
- f.puts message == :default ? '# First line is the action, lines after are added as a note' : message
104
- end
105
- end
106
-
107
- pid = Process.fork { system("#{editor_with_args} #{tmpfile.path}") }
108
-
109
- trap('INT') do
110
- begin
111
- Process.kill(9, pid)
112
- rescue StandardError
113
- Errno::ESRCH
114
- end
115
- tmpfile.unlink
116
- tmpfile.close!
117
- exit 0
118
- end
119
-
120
- Process.wait(pid)
121
-
122
- begin
123
- if $?.exitstatus == 0
124
- input = IO.read(tmpfile.path)
125
- else
126
- exit_now! 'Cancelled'
127
- end
128
- ensure
129
- tmpfile.close
130
- tmpfile.unlink
131
- end
132
-
133
- input.split(/\n/).delete_if(&:ignore?).join("\n")
134
- end
135
-
136
- ##
137
- ## Takes a multi-line string and formats it as an entry
138
- ##
139
- ## @param input [String] The string to parse
140
- ##
141
- ## @return [Array] [[String]title, [Note]note]
142
- ##
143
- def format_input(input)
144
- notify('No content in entry', exit_code: 1) if input.nil? || input.strip.empty?
145
-
146
- input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
147
- title = input_lines[0]&.strip
148
- notify('{br}No content in first line{x}', exit_code: 1) if title.nil? || title.strip.empty?
149
-
150
- title.expand_date_tags
151
-
152
- note = if input_lines.length > 1
153
- input_lines[1..-1]
154
- else
155
- []
156
- end
157
-
158
-
159
- unless note.empty?
160
- note.map!(&:strip)
161
- note.delete_if { |l| l =~ /^\s*$/ || l =~ /^#/ }
162
- end
163
-
164
- [title, note]
165
- end
166
-
167
58
  ##
168
59
  ## Helper function to colorize the Y/N prompt
169
60
  ##
@@ -213,20 +104,7 @@ module NA
213
104
  f.puts(content)
214
105
  end
215
106
  save_working_dir(target)
216
- notify("{y}Created {bw}#{target}")
217
- end
218
-
219
- ##
220
- ## Use the *nix `find` command to locate files matching NA.extension
221
- ##
222
- ## @param depth [Number] The depth at which to search
223
- ##
224
- def find_files(depth: 1)
225
- return [NA.global_file] if NA.global_file
226
-
227
- files = `find . -maxdepth #{depth} -name "*.#{NA.extension}"`.strip.split("\n")
228
- files.each { |f| save_working_dir(File.expand_path(f)) }
229
- files
107
+ notify("#{NA.theme[:warning]}Created #{NA.theme[:file]}#{target}")
230
108
  end
231
109
 
232
110
  ##
@@ -242,7 +120,7 @@ module NA
242
120
  def select_file(files, multiple: false)
243
121
  res = choose_from(files, prompt: multiple ? 'Select files' : 'Select a file', multiple: multiple)
244
122
 
245
- notify('{r}No file selected, cancelled', exit_code: 1) unless res && res.length.positive?
123
+ notify("#{NA.theme[:error]}No file selected, cancelled", exit_code: 1) unless res && res.length.positive?
246
124
 
247
125
  res
248
126
  end
@@ -257,37 +135,41 @@ module NA
257
135
  end
258
136
 
259
137
  def find_projects(target)
260
- _, _, projects = parse_actions(require_na: false, file_path: target)
261
- projects
138
+ todo = NA::Todo.new(require_na: false, file_path: target)
139
+ todo.projects
262
140
  end
263
141
 
264
142
  def find_actions(target, search, tagged = nil, all: false, done: false)
265
- _, actions, projects = parse_actions(search: search, require_na: false, file_path: target, tag: tagged, done: done)
266
-
267
- unless actions.count.positive?
268
- NA.notify("{r}No matching actions found in {bw}#{File.basename(target, ".#{NA.extension}")}")
143
+ todo = NA::Todo.new({ search: search,
144
+ require_na: false,
145
+ file_path: target,
146
+ tag: tagged,
147
+ done: done })
148
+
149
+ unless todo.actions.count.positive?
150
+ NA.notify("#{NA.theme[:error]}No matching actions found in #{File.basename(target, ".#{NA.extension}").highlight_filename}")
269
151
  return
270
152
  end
271
153
 
272
- return [projects, actions] if actions.count == 1 || all
154
+ return [todo.projects, todo.actions] if todo.actions.count == 1 || all
273
155
 
274
- options = actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
156
+ options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
275
157
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
276
158
 
277
- NA.notify('{r}Cancelled', exit_code: 1) unless res && res.length.positive?
159
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless res && res.length.positive?
278
160
 
279
- selected = []
161
+ selected = NA::Actions.new
280
162
  res.each do |result|
281
163
  idx = result.match(/^(\d+)(?= % )/)[1]
282
- action = actions.select { |a| a.line == idx.to_i }.first
164
+ action = todo.actions.select { |a| a.line == idx.to_i }.first
283
165
  selected.push(action)
284
166
  end
285
- [projects, selected]
167
+ [todo.projects, selected]
286
168
  end
287
169
 
288
170
  def insert_project(target, project, projects)
289
171
  path = project.split(%r{[:/]})
290
- _, _, projects = parse_actions(file_path: target)
172
+ todo = NA::Todo.new(file_path: target)
291
173
  built = []
292
174
  last_match = nil
293
175
  final_match = nil
@@ -295,7 +177,7 @@ module NA
295
177
  matches = nil
296
178
  path.each_with_index do |part, i|
297
179
  built.push(part)
298
- matches = projects.select { |proj| proj.project =~ /^#{built.join(':')}/i }
180
+ matches = todo.projects.select { |proj| proj.project =~ /^#{built.join(':')}/i }
299
181
  if matches.count.zero?
300
182
  final_match = last_match
301
183
  new_path = path.slice(i, path.count - i)
@@ -315,12 +197,12 @@ module NA
315
197
  end
316
198
 
317
199
  if new_path.join('') =~ /Archive/i
318
- line = projects.last.last_line
200
+ line = todo.projects.last.last_line
319
201
  content = content.split(/\n/).insert(line, input.join("\n")).join("\n")
320
202
  else
321
203
  split = content.split(/\n/)
322
- before = split.slice(0, projects.first.line).join("\n")
323
- after = split.slice(projects.first.line, split.count - projects.first.line).join("\n")
204
+ before = split.slice(0, todo.projects.first.line).join("\n")
205
+ after = split.slice(todo.projects.first.line, split.count - todo.projects.first.line).join("\n")
324
206
  content = "#{before}\n#{input.join("\n")}\n#{after}"
325
207
  end
326
208
 
@@ -344,34 +226,6 @@ module NA
344
226
  new_project
345
227
  end
346
228
 
347
- def process_action(action, priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
348
- string = action.action
349
-
350
- if priority&.positive?
351
- string.gsub!(/(?<=\A| )@priority\(\d+\)/, '').strip!
352
- string += " @priority(#{priority})"
353
- end
354
-
355
- add_tag.each do |tag|
356
- string.gsub!(/(?<=\A| )@#{tag.gsub(/([()*?])/, '\\\\1')}(\(.*?\))?/, '')
357
- string.strip!
358
- string += " @#{tag}"
359
- end
360
-
361
- remove_tag.each do |tag|
362
- string.gsub!(/(?<=\A| )@#{tag.gsub(/([()*?])/, '\\\\1')}(\(.*?\))?/, '')
363
- string.strip!
364
- end
365
-
366
- string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /(?<=\A| )@done/
367
-
368
- action.action = string
369
- action.action.expand_date_tags
370
- action.note = note unless note.empty?
371
-
372
- action
373
- end
374
-
375
229
  def update_action(target,
376
230
  search,
377
231
  add: nil,
@@ -397,11 +251,11 @@ module NA
397
251
  project = project.sub(/:$/, '')
398
252
  target_proj = projects.select { |pr| pr.project =~ /#{project.gsub(/:/, '.*?:.*?')}/i }.first
399
253
  if target_proj.nil?
400
- res = NA.yn(NA::Color.template("{y}Project {bw}#{project}{xy} doesn't exist, add it"), default: true)
254
+ res = NA.yn(NA::Color.template("#{NA.theme[:warning]}Project #{NA.theme[:file]}#{project}#{NA.theme[:warning]} doesn't exist, add it"), default: true)
401
255
  if res
402
256
  target_proj = insert_project(target, project, projects)
403
257
  else
404
- NA.notify('{x}Cancelled', exit_code: 1)
258
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1)
405
259
  end
406
260
  end
407
261
  end
@@ -410,24 +264,24 @@ module NA
410
264
 
411
265
  if add.is_a?(Action)
412
266
  add_tag ||= []
413
- action = process_action(add, priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
267
+ add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
414
268
 
415
269
  projects = find_projects(target)
416
270
 
417
271
  target_proj = if target_proj
418
272
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
419
273
  else
420
- projects.select { |proj| proj.project =~ /^#{action.parent.join(':')}$/ }.first
274
+ projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/ }.first
421
275
  end
422
276
 
423
- NA.notify("{r}Error parsing project #{target}", exit_code: 1) if target_proj.nil?
277
+ NA.notify("#{NA.theme[:error]}Error parsing project #{NA.theme[:filename]}#{target}", exit_code: 1) if target_proj.nil?
424
278
 
425
279
  indent = "\t" * target_proj.indent
426
280
  note = note.split("\n") unless note.is_a?(Array)
427
281
  note = if note.empty?
428
- action.note
282
+ add.note
429
283
  else
430
- overwrite ? note : action.note.concat(note)
284
+ overwrite ? note : add.note.concat(note)
431
285
  end
432
286
 
433
287
  note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"
@@ -449,9 +303,9 @@ module NA
449
303
  target_line = target_proj.line + 1
450
304
  end
451
305
 
452
- contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")
306
+ contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
453
307
 
454
- notify(action.pretty)
308
+ notify(add.pretty)
455
309
  else
456
310
  _, actions = find_actions(target, search, tagged, done: done, all: all)
457
311
 
@@ -464,12 +318,13 @@ module NA
464
318
  projects = shift_index_after(projects, action.line, action.note.count + 1)
465
319
 
466
320
  if edit
467
- new_action, new_note = format_input(fork_editor("#{action.action}\n#{action.note.join("\n")}"))
321
+ editor_content = "#{action.action}\n#{action.note.join("\n")}"
322
+ new_action, new_note = Editor.format_input(Editor.fork_editor(editor_content))
468
323
  action.action = new_action
469
324
  action.note = new_note
470
325
  end
471
326
 
472
- action = process_action(action, priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
327
+ action.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
473
328
 
474
329
  target_proj = if target_proj
475
330
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
@@ -504,7 +359,6 @@ module NA
504
359
  target_line = target_proj.line + 1
505
360
  end
506
361
 
507
-
508
362
  contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")
509
363
 
510
364
  notify(action.pretty)
@@ -514,7 +368,11 @@ module NA
514
368
  backup_file(target)
515
369
  File.open(target, 'w') { |f| f.puts contents.join("\n") }
516
370
 
517
- add ? notify("{by}Task added to {bw}#{target}") : notify("{by}Task updated in {bw}#{target}")
371
+ if add
372
+ notify("#{NA.theme[:success]}Task added to #{NA.theme[:filename]}#{target}")
373
+ else
374
+ notify("#{NA.theme[:success]}Task updated in #{NA.theme[:filename]}#{target}")
375
+ end
518
376
  end
519
377
 
520
378
  ##
@@ -535,7 +393,6 @@ module NA
535
393
  else
536
394
  project = NA.cwd
537
395
  end
538
- puts [add_tag, project]
539
396
  end
540
397
 
541
398
  action = Action.new(file, project, parent, action, nil, note)
@@ -560,6 +417,11 @@ module NA
560
417
  parents
561
418
  end
562
419
 
420
+ # Output an Omnifocus-friendly action list
421
+ #
422
+ # @param children The children
423
+ # @param level The indent level
424
+ #
563
425
  def output_children(children, level = 1)
564
426
  out = []
565
427
  indent = "\t" * level
@@ -595,220 +457,190 @@ module NA
595
457
  out
596
458
  end
597
459
 
460
+ def edit_file(file: nil, app: nil)
461
+ os_open(file, app: app) if file && File.exist?(file)
462
+ end
463
+
598
464
  ##
599
- ## Pretty print a list of actions
465
+ ## Use the *nix `find` command to locate files matching NA.extension
600
466
  ##
601
- ## @param actions [Array] The actions
602
- ## @param depth [Number] The depth
603
- ## @param files [Array] The files actions originally came from
604
- ## @param regexes [Array] The regexes used to gather actions
467
+ ## @param depth [Number] The depth at which to search
605
468
  ##
606
- def output_actions(actions, depth, files: nil, regexes: [], notes: false, nest: false, nest_projects: false)
607
- return if files.nil?
608
-
609
- if nest
610
- template = '%parent%action'
611
-
612
- parent_files = {}
613
- out = []
614
-
615
- if nest_projects
616
- actions.each do |action|
617
- if parent_files.key?(action.file)
618
- parent_files[action.file].push(action)
619
- else
620
- parent_files[action.file] = [action]
621
- end
622
- end
623
-
624
- parent_files.each do |file, acts|
625
- projects = project_hierarchy(acts)
626
- out.push("#{file.sub(%r{^./}, '').shorten_path}:")
627
- out.concat(output_children(projects, 0))
628
- end
629
- else
630
- template = '%parent%action'
631
-
632
- actions.each do |action|
633
- if parent_files.key?(action.file)
634
- parent_files[action.file].push(action)
635
- else
636
- parent_files[action.file] = [action]
637
- end
638
- end
469
+ def find_files(depth: 1)
470
+ return [NA.global_file] if NA.global_file
639
471
 
640
- parent_files.each do |k, v|
641
- out.push("#{k.sub(%r{^\./}, '')}:")
642
- v.each do |a|
643
- out.push("\t- [#{a.parent.join('/')}] #{a.action}")
644
- out.push("\t\t#{a.note.join("\n\t\t")}") unless a.note.empty?
645
- end
646
- end
647
- end
648
- NA::Pager.page out.join("\n")
649
- else
650
- template = if files.count.positive?
651
- if files.count == 1
652
- '%parent%action'
653
- else
654
- '%filename%parent%action'
655
- end
656
- elsif find_files(depth: depth).count > 1
657
- if depth > 1
658
- '%filename%parent%action'
659
- else
660
- '%project%parent%action'
661
- end
662
- else
663
- '%parent%action'
664
- end
665
- template += '%note' if notes
666
-
667
- files.map { |f| notify("{dw}#{f}", debug: true) } if files
668
-
669
- output = actions.map { |action| action.pretty(template: { output: template }, regexes: regexes, notes: notes) }
670
- NA::Pager.page(output.join("\n"))
671
- end
472
+ files = `find . -maxdepth #{depth} -name "*.#{NA.extension}"`.strip.split("\n")
473
+ files.each { |f| save_working_dir(File.expand_path(f)) }
474
+ files
672
475
  end
673
476
 
674
- ##
675
- ## Read a todo file and create a list of actions
676
- ##
677
- ## @param depth [Number] The directory depth
678
- ## to search for files
679
- ## @param done [Boolean] include @done actions
680
- ## @param query [Hash] The todo file query
681
- ## @param tag [Array] Tags to search for
682
- ## @param search [String] A search string
683
- ## @param negate [Boolean] Invert results
684
- ## @param regex [Boolean] Interpret as
685
- ## regular expression
686
- ## @param project [String] The project
687
- ## @param require_na [Boolean] Require @na tag
688
- ## @param file_path [String] file path to parse
689
- ##
690
- def parse_actions(depth: 1, done: false, query: nil, tag: nil, search: nil, negate: false, regex: false, project: nil, require_na: true, file_path: nil)
691
- actions = []
692
- required = []
693
- optional = []
694
- negated = []
695
- required_tag = []
696
- optional_tag = []
697
- negated_tag = []
698
- projects = []
699
-
700
- notify("{dw}Tags: #{tag}", debug:true)
701
- notify("{dw}Search: #{search}", debug:true)
702
-
703
- tag&.each do |t|
704
- unless t[:tag].nil?
705
- if negate
706
- optional_tag.push(t) if t[:negate]
707
- required_tag.push(t) if t[:required] && t[:negate]
708
- negated_tag.push(t) unless t[:negate]
709
- else
710
- optional_tag.push(t) unless t[:negate]
711
- required_tag.push(t) if t[:required] && !t[:negate]
712
- negated_tag.push(t) if t[:negate]
713
- end
714
- end
715
- end
716
-
717
- unless search.nil? || search.empty?
718
- if regex || search.is_a?(String)
719
- if negate
720
- negated.push(search)
721
- else
722
- optional.push(search)
723
- required.push(search)
724
- end
725
- else
726
- search.each do |t|
727
- opt, req, neg = parse_search(t, negate)
728
- optional.concat(opt)
729
- required.concat(req)
730
- negated.concat(neg)
731
- end
732
- end
477
+ def find_files_matching(options = {})
478
+ defaults = {
479
+ depth: 1,
480
+ done: false,
481
+ file_path: nil,
482
+ negate: false,
483
+ project: nil,
484
+ query: nil,
485
+ regex: false,
486
+ require_na: true,
487
+ search: nil,
488
+ tag: nil
489
+ }
490
+ opts = defaults.merge(options)
491
+
492
+ files = find_files(depth: options[:depth])
493
+
494
+ files.delete_if do |file|
495
+ todo = NA::Todo.new({
496
+ depth: options[:depth],
497
+ done: options[:done],
498
+ file_path: file,
499
+ negate: options[:negate],
500
+ project: options[:project],
501
+ query: options[:query],
502
+ regex: options[:regex],
503
+ require_na: options[:require_na],
504
+ search: options[:search],
505
+ tag: options[:tag]
506
+ })
507
+ todo.actions.length.zero?
733
508
  end
734
509
 
735
- files = if !file_path.nil?
736
- [file_path]
737
- elsif query.nil?
738
- find_files(depth: depth)
739
- else
740
- match_working_dir(query)
741
- end
742
-
743
- files.each do |file|
744
- save_working_dir(File.expand_path(file))
745
- content = file.read_file
746
- indent_level = 0
747
- parent = []
748
- in_action = false
749
- content.split("\n").each.with_index do |line, idx|
750
- if line.project?
751
- in_action = false
752
- proj = line.project
753
- indent = line.indent_level
754
-
755
- if indent.zero? # top level project
756
- parent = [proj]
757
- elsif indent <= indent_level # if indent level is same or less, split parent before indent level and append
758
- parent.slice!(indent, parent.count - indent)
759
- parent.push(proj)
760
- else # if indent level is greater, append project to parent
761
- parent.push(proj)
762
- end
510
+ files
511
+ end
763
512
 
764
- projects.push(NA::Project.new(parent.join(':'), indent, idx, idx))
513
+ ##
514
+ ## Find a matching path using semi-fuzzy matching.
515
+ ## Search tokens can include ! and + to negate or make
516
+ ## required.
517
+ ##
518
+ ## @param search [Array] search tokens to
519
+ ## match
520
+ ## @param distance [Integer] allowed distance
521
+ ## between characters
522
+ ## @param require_last [Boolean] require regex to
523
+ ## match last element of path
524
+ ##
525
+ ## @return [Array] array of matching directories/todo files
526
+ ##
527
+ def match_working_dir(search, distance: 1, require_last: true)
528
+ file = database_path
529
+ NA.notify("#{NA.theme[:error]}No na database found", exit_code: 1) unless File.exist?(file)
765
530
 
766
- indent_level = indent
767
- elsif line.blank?
768
- in_action = false
769
- elsif line.action?
770
- in_action = false
531
+ dirs = file.read_file.split("\n")
771
532
 
772
- action = line.action
773
- new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
533
+ optional = search.filter { |s| !s[:negate] }.map { |t| t[:token] }
534
+ required = search.filter { |s| s[:required] && !s[:negate] }.map { |t| t[:token] }
535
+ negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }
774
536
 
775
- projects[-1].last_line = idx if projects.count.positive?
537
+ optional.push('*') if optional.count.zero? && required.count.zero? && negated.count.positive?
538
+ if optional == negated
539
+ required = ['*']
540
+ optional = ['*']
541
+ end
776
542
 
777
- next if line.done? && !done
543
+ NA.notify("Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
544
+ NA.notify("Required directory regex: {x}#{required.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
545
+ NA.notify("Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: distance, require_last: false) }}", debug: true)
778
546
 
779
- next if require_na && !line.na?
547
+ if require_last
548
+ dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
549
+ else
550
+ dirs.delete_if do |d|
551
+ !d.sub(/\.#{NA.extension}$/, '')
552
+ .dir_matches(any: optional, all: required, none: negated, distance: 2, require_last: false)
553
+ end
554
+ end
780
555
 
781
- has_search = !optional.empty? || !required.empty? || !negated.empty?
556
+ dirs = dirs.sort_by { |d| File.basename(d) }.uniq
557
+ if dirs.empty? && require_last
558
+ NA.notify("#{NA.theme[:warning]}No matches, loosening search", debug: true)
559
+ match_working_dir(search, distance: 2, require_last: false)
560
+ else
561
+ dirs
562
+ end
563
+ end
782
564
 
783
- next if has_search && !new_action.search_match?(any: optional,
784
- all: required,
785
- none: negated)
565
+ ##
566
+ ## Save a todo file path to the database
567
+ ##
568
+ ## @param todo_file The todo file path
569
+ ##
570
+ def save_working_dir(todo_file)
571
+ file = database_path
572
+ content = File.exist?(file) ? file.read_file : ''
573
+ dirs = content.split(/\n/)
574
+ dirs.push(File.expand_path(todo_file))
575
+ dirs.sort!.uniq!
576
+ File.open(file, 'w') { |f| f.puts dirs.join("\n") }
577
+ end
786
578
 
787
- if project
788
- rx = project.split(%r{[/:]}).join('.*?/.*?')
789
- next unless parent.join('/') =~ Regexp.new(rx, Regexp::IGNORECASE)
790
- end
579
+ ##
580
+ ## Save a backed-up file to the database
581
+ ##
582
+ ## @param file The file
583
+ ##
584
+ def save_modified_file(file)
585
+ db = database_path(file: 'last_modified.txt')
586
+ file = File.expand_path(file)
587
+ files = IO.read(db).split(/\n/).map(&:strip)
588
+ files.delete(file)
589
+ files << file
590
+ File.open(db, 'w') { |f| f.puts(files.join("\n")) }
591
+ end
791
592
 
792
- has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
793
- next if has_tag && !new_action.tags_match?(any: optional_tag,
794
- all: required_tag,
795
- none: negated_tag)
593
+ ##
594
+ ## Get the last modified file from the database
595
+ ##
596
+ ## @param search The search
597
+ ##
598
+ def last_modified_file(search: nil)
599
+ db = database_path(file: 'last_modified.txt')
600
+ files = IO.read(db).split(/\n/).map(&:strip)
601
+ files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
602
+ files.last
603
+ end
796
604
 
797
- actions.push(new_action)
798
- in_action = true
799
- elsif in_action
800
- actions[-1].note.push(line.strip) if actions.count.positive?
801
- projects[-1].last_line = idx if projects.count.positive?
802
- end
803
- end
804
- projects = projects.dup
605
+ ##
606
+ ## Get last modified file and restore a backup
607
+ ##
608
+ ## @param search The search
609
+ ##
610
+ def restore_last_modified_file(search: nil)
611
+ file = last_modified_file(search: search)
612
+ if file
613
+ restore_modified_file(file)
614
+ else
615
+ NA.notify("#{NA.theme[:error]}No matching file found")
805
616
  end
617
+ end
806
618
 
807
- [files, actions, projects]
619
+ ##
620
+ ## Restore a file from backup
621
+ ##
622
+ ## @param file The file
623
+ ##
624
+ def restore_modified_file(file)
625
+ bak_file = File.join(File.dirname(file), ".#{File.basename(file)}.bak")
626
+ if File.exist?(bak_file)
627
+ FileUtils.mv(bak_file, file)
628
+ NA.notify("#{NA.theme[:success]}Backup restored for #{file.highlight_filename}")
629
+ else
630
+ NA.notify("#{NA.theme[:error]}Backup file for #{file.highlight_filename} not found")
631
+ end
808
632
  end
809
633
 
810
- def edit_file(file: nil, app: nil)
811
- os_open(file, app: app) if file && File.exist?(file)
634
+ ##
635
+ ## Get path to database of known todo files
636
+ ##
637
+ ## @return [String] File path
638
+ ##
639
+ def database_path(file: 'tdlist.txt')
640
+ db_dir = File.expand_path('~/.local/share/na')
641
+ # Create directory if needed
642
+ FileUtils.mkdir_p(db_dir) unless File.directory?(db_dir)
643
+ File.join(db_dir, file)
812
644
  end
813
645
 
814
646
  ##
@@ -848,7 +680,7 @@ module NA
848
680
  elsif !file_path.nil?
849
681
  [file_path]
850
682
  elsif query.nil?
851
- find_files(depth: depth)
683
+ NA.find_files(depth: depth)
852
684
  else
853
685
  match_working_dir(query)
854
686
  end
@@ -873,13 +705,13 @@ module NA
873
705
  else
874
706
  file = database_path
875
707
  content = File.exist?(file) ? file.read_file.strip : ''
876
- notify('{br}Database empty', exit_code: 1) if content.empty?
708
+ notify("#{NA.theme[:error]}Database empty", exit_code: 1) if content.empty?
877
709
 
878
710
  content.split(/\n/)
879
711
  end
880
712
 
881
713
  dirs.map! do |dir|
882
- "{xg}#{dir.sub(/^#{ENV['HOME']}/, '~').sub(%r{/([^/]+)\.#{NA.extension}$}, '/{xbw}\1{x}')}"
714
+ dir.highlight_filename
883
715
  end
884
716
 
885
717
  puts NA::Color.template(dirs.join("\n"))
@@ -892,13 +724,13 @@ module NA
892
724
 
893
725
  if searches.key?(title)
894
726
  res = yn('Overwrite existing definition?', default: true)
895
- notify('{r}Cancelled', exit_code: 0) unless res
727
+ notify("#{NA.theme[:error]}Cancelled", exit_code: 0) unless res
896
728
 
897
729
  end
898
730
 
899
731
  searches[title] = search
900
732
  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
901
- NA.notify("{y}Search #{title} saved", exit_code: 0)
733
+ NA.notify("#{NA.theme[:success]}Search #{NA.theme[:filename]}#{title}#{NA.theme[:success]} saved", exit_code: 0)
902
734
  end
903
735
 
904
736
  def load_searches
@@ -918,49 +750,37 @@ module NA
918
750
  end
919
751
 
920
752
  def delete_search(strings = nil)
921
- NA.notify('{r}Name search required', exit_code: 1) if strings.nil? || strings.empty?
753
+ NA.notify("#{NA.theme[:error]}Name of search required", exit_code: 1) if strings.nil? || strings.empty?
922
754
 
923
755
  file = database_path(file: 'saved_searches.yml')
924
- NA.notify('{r}No search definitions file found', exit_code: 1) unless File.exist?(file)
756
+ NA.notify("#{NA.theme[:error]}No search definitions file found", exit_code: 1) unless File.exist?(file)
925
757
 
926
758
  searches = YAML.safe_load(file.read_file)
927
759
  keys = searches.keys.delete_if { |k| k !~ /(#{strings.join('|')})/ }
928
760
 
929
- res = yn(NA::Color.template(%({y}Remove #{keys.count > 1 ? 'searches' : 'search'} {bw}"#{keys.join(', ')}"{x})),
761
+ res = yn(NA::Color.template(%(#{NA.theme[:warning]}Remove #{keys.count > 1 ? 'searches' : 'search'} #{NA.theme[:file]}"#{keys.join(', ')}"{x})),
930
762
  default: false)
931
763
 
932
- NA.notify('{r}Cancelled', exit_code: 1) unless res
764
+ NA.notify("#{NA.theme[:error]}Cancelled", exit_code: 1) unless res
933
765
 
934
766
  searches.delete_if { |k| keys.include?(k) }
935
767
 
936
768
  File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
937
769
 
938
- NA.notify("{y}Deleted {bw}#{keys.count}{xy} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0)
770
+ NA.notify("#{NA.theme[:warning]}Deleted {bw}#{keys.count}{x}#{NA.theme[:warning]} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0)
939
771
  end
940
772
 
941
773
  def edit_searches
942
774
  file = database_path(file: 'saved_searches.yml')
943
775
  searches = load_searches
944
776
 
945
- NA.notify('{r}No search definitions found', exit_code: 1) unless searches.count.positive?
777
+ NA.notify("#{NA.theme[:error]}No search definitions found", exit_code: 1) unless searches.count.positive?
946
778
 
947
- editor = ENV['EDITOR']
948
- NA.notify('{r}No $EDITOR defined', exit_code: 1) unless editor && TTY::Which.exist?(editor)
779
+ editor = NA.default_editor
780
+ NA.notify("#{NA.theme[:error]}No $EDITOR defined", exit_code: 1) unless editor && TTY::Which.exist?(editor)
949
781
 
950
782
  system %(#{editor} "#{file}")
951
- NA.notify("Opened #{file} in #{editor}", exit_code: 0)
952
- end
953
-
954
- ##
955
- ## Get path to database of known todo files
956
- ##
957
- ## @return [String] File path
958
- ##
959
- def database_path(file: 'tdlist.txt')
960
- db_dir = File.expand_path('~/.local/share/na')
961
- # Create directory if needed
962
- FileUtils.mkdir_p(db_dir) unless File.directory?(db_dir)
963
- File.join(db_dir, file)
783
+ NA.notify("#{NA.theme[:success]}Opened #{file} in #{editor}", exit_code: 0)
964
784
  end
965
785
 
966
786
  ##
@@ -972,56 +792,8 @@ module NA
972
792
  file = ".#{File.basename(target)}.bak"
973
793
  backup = File.join(File.dirname(target), file)
974
794
  FileUtils.cp(target, backup)
975
- NA.notify("{dw}Backup file created at #{backup}", debug: true)
976
- end
977
-
978
- ##
979
- ## Find a matching path using semi-fuzzy matching.
980
- ## Search tokens can include ! and + to negate or make
981
- ## required.
982
- ##
983
- ## @param search [Array] search tokens to
984
- ## match
985
- ## @param distance [Integer] allowed distance
986
- ## between characters
987
- ## @param require_last [Boolean] require regex to
988
- ## match last element of path
989
- ##
990
- ## @return [Array] array of matching directories/todo files
991
- ##
992
- def match_working_dir(search, distance: 1, require_last: true)
993
- file = database_path
994
- notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
995
-
996
- dirs = file.read_file.split("\n")
997
-
998
- optional = search.filter { |s| !s[:negate] }.map { |t| t[:token] }
999
- required = search.filter { |s| s[:required] }.map { |t| t[:token] }
1000
- negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }
1001
-
1002
- optional.push('*') if optional.count.zero? && required.count.zero? && negated.count.positive?
1003
- if optional == negated
1004
- required = ['*']
1005
- optional = ['*']
1006
- end
1007
-
1008
- NA.notify("{dw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
1009
- NA.notify("{dw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
1010
- NA.notify("{dw}Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: 1, require_last: false) }}", debug: true)
1011
-
1012
- if require_last
1013
- dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
1014
- else
1015
- dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated, distance: 2, require_last: false) }
1016
- end
1017
-
1018
- dirs = dirs.sort.uniq
1019
- if dirs.empty? && require_last
1020
- NA.notify("{y}No matches, loosening search", debug: true)
1021
- match_working_dir(search, distance: 2, require_last: false)
1022
- else
1023
- dirs
1024
- end
795
+ save_modified_file(target)
796
+ NA.notify("#{NA.theme[:warning]}Backup file created at #{backup.highlight_filename}", debug: true)
1025
797
  end
1026
798
 
1027
799
  private
@@ -1057,15 +829,15 @@ module NA
1057
829
  '--item.foreground=""'
1058
830
  ]
1059
831
  args.push '--no-limit' if multiple
1060
- puts NS::Color.template("{bw}#{prompt}{x}")
832
+ puts NS::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")
1061
833
  `echo #{Shellwords.escape(options.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`.strip
1062
834
  else
1063
835
  reader = TTY::Reader.new
1064
836
  puts
1065
837
  options.each.with_index do |f, i|
1066
- puts NA::Color.template(format("{bw}%<idx> 2d{xw}) {y}%<action>s{x}\n", idx: i + 1, action: f))
838
+ puts NA::Color.template(format("#{NA.theme[:prompt]}%<idx> 2d{xw}) #{NA.theme[:file]}%<action>s{x}\n", idx: i + 1, action: f))
1067
839
  end
1068
- result = reader.read_line(NA::Color.template("{bw}#{prompt}{x}")).strip
840
+ result = reader.read_line(NA::Color.template("#{NA.theme[:prompt]}#{prompt}{x}")).strip
1069
841
  result.to_i&.positive? ? options[result.to_i - 1] : nil
1070
842
  end
1071
843
 
@@ -1074,39 +846,6 @@ module NA
1074
846
  multiple ? res.split(/\n/) : res
1075
847
  end
1076
848
 
1077
- def parse_search(tag, negate)
1078
- required = []
1079
- optional = []
1080
- negated = []
1081
- new_rx = tag[:token].to_s.wildcard_to_rx
1082
-
1083
- if negate
1084
- optional.push(new_rx) if tag[:negate]
1085
- required.push(new_rx) if tag[:required] && tag[:negate]
1086
- negated.push(new_rx) unless tag[:negate]
1087
- else
1088
- optional.push(new_rx) unless tag[:negate]
1089
- required.push(new_rx) if tag[:required] && !tag[:negate]
1090
- negated.push(new_rx) if tag[:negate]
1091
- end
1092
-
1093
- [optional, required, negated]
1094
- end
1095
-
1096
- ##
1097
- ## Save a todo file path to the database
1098
- ##
1099
- ## @param todo_file The todo file path
1100
- ##
1101
- def save_working_dir(todo_file)
1102
- file = database_path
1103
- content = File.exist?(file) ? file.read_file : ''
1104
- dirs = content.split(/\n/)
1105
- dirs.push(File.expand_path(todo_file))
1106
- dirs.sort!.uniq!
1107
- File.open(file, 'w') { |f| f.puts dirs.join("\n") }
1108
- end
1109
-
1110
849
  ##
1111
850
  ## macOS open command
1112
851
  ##
@@ -1139,7 +878,7 @@ module NA
1139
878
  if TTY::Which.exist?('xdg-open')
1140
879
  `xdg-open #{Shellwords.escape(file)}`
1141
880
  else
1142
- notify('{r}Unable to determine executable for `xdg-open`.')
881
+ notify("#{NA.theme[:error]}Unable to determine executable for `xdg-open`.")
1143
882
  end
1144
883
  end
1145
884
  end