na 1.2.35 → 1.2.37

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.
@@ -3,6 +3,8 @@
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
 
8
10
  ##
@@ -16,7 +18,11 @@ module NA
16
18
  def notify(msg, exit_code: false, debug: false)
17
19
  return if debug && !NA.verbose
18
20
 
19
- $stderr.puts NA::Color.template("{x}#{msg}{x}")
21
+ if debug
22
+ $stderr.puts NA::Color.template("{xdw}#{msg}{x}")
23
+ else
24
+ $stderr.puts NA::Color.template("{x}#{msg}{x}")
25
+ end
20
26
  Process.exit exit_code if exit_code
21
27
  end
22
28
 
@@ -45,125 +51,6 @@ module NA
45
51
  res.empty? ? default : res =~ /y/i
46
52
  end
47
53
 
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
54
  ##
168
55
  ## Helper function to colorize the Y/N prompt
169
56
  ##
@@ -216,19 +103,6 @@ module NA
216
103
  notify("{y}Created {bw}#{target}")
217
104
  end
218
105
 
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
230
- end
231
-
232
106
  ##
233
107
  ## Select from multiple files
234
108
  ##
@@ -257,37 +131,41 @@ module NA
257
131
  end
258
132
 
259
133
  def find_projects(target)
260
- _, _, projects = parse_actions(require_na: false, file_path: target)
261
- projects
134
+ todo = NA::Todo.new(require_na: false, file_path: target)
135
+ todo.projects
262
136
  end
263
137
 
264
138
  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)
139
+ todo = NA::Todo.new({ search: search,
140
+ require_na: false,
141
+ file_path: target,
142
+ tag: tagged,
143
+ done: done })
266
144
 
267
- unless actions.count.positive?
145
+ unless todo.actions.count.positive?
268
146
  NA.notify("{r}No matching actions found in {bw}#{File.basename(target, ".#{NA.extension}")}")
269
147
  return
270
148
  end
271
149
 
272
- return [projects, actions] if actions.count == 1 || all
150
+ return [todo.projects, todo.actions] if todo.actions.count == 1 || all
273
151
 
274
- options = actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
152
+ options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
275
153
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
276
154
 
277
155
  NA.notify('{r}Cancelled', exit_code: 1) unless res && res.length.positive?
278
156
 
279
- selected = []
157
+ selected = NA::Actions.new
280
158
  res.each do |result|
281
159
  idx = result.match(/^(\d+)(?= % )/)[1]
282
- action = actions.select { |a| a.line == idx.to_i }.first
160
+ action = todo.actions.select { |a| a.line == idx.to_i }.first
283
161
  selected.push(action)
284
162
  end
285
- [projects, selected]
163
+ [todo.projects, selected]
286
164
  end
287
165
 
288
166
  def insert_project(target, project, projects)
289
167
  path = project.split(%r{[:/]})
290
- _, _, projects = parse_actions(file_path: target)
168
+ todo = NA::Todo.new(file_path: target)
291
169
  built = []
292
170
  last_match = nil
293
171
  final_match = nil
@@ -295,7 +173,7 @@ module NA
295
173
  matches = nil
296
174
  path.each_with_index do |part, i|
297
175
  built.push(part)
298
- matches = projects.select { |proj| proj.project =~ /^#{built.join(':')}/i }
176
+ matches = todo.projects.select { |proj| proj.project =~ /^#{built.join(':')}/i }
299
177
  if matches.count.zero?
300
178
  final_match = last_match
301
179
  new_path = path.slice(i, path.count - i)
@@ -315,12 +193,12 @@ module NA
315
193
  end
316
194
 
317
195
  if new_path.join('') =~ /Archive/i
318
- line = projects.last.last_line
196
+ line = todo.projects.last.last_line
319
197
  content = content.split(/\n/).insert(line, input.join("\n")).join("\n")
320
198
  else
321
199
  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")
200
+ before = split.slice(0, todo.projects.first.line).join("\n")
201
+ after = split.slice(todo.projects.first.line, split.count - todo.projects.first.line).join("\n")
324
202
  content = "#{before}\n#{input.join("\n")}\n#{after}"
325
203
  end
326
204
 
@@ -344,34 +222,6 @@ module NA
344
222
  new_project
345
223
  end
346
224
 
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
225
  def update_action(target,
376
226
  search,
377
227
  add: nil,
@@ -410,14 +260,14 @@ module NA
410
260
 
411
261
  if add.is_a?(Action)
412
262
  add_tag ||= []
413
- action = process_action(add, priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
263
+ add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
414
264
 
415
265
  projects = find_projects(target)
416
266
 
417
267
  target_proj = if target_proj
418
268
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
419
269
  else
420
- projects.select { |proj| proj.project =~ /^#{action.parent.join(':')}$/ }.first
270
+ projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/ }.first
421
271
  end
422
272
 
423
273
  NA.notify("{r}Error parsing project #{target}", exit_code: 1) if target_proj.nil?
@@ -425,9 +275,9 @@ module NA
425
275
  indent = "\t" * target_proj.indent
426
276
  note = note.split("\n") unless note.is_a?(Array)
427
277
  note = if note.empty?
428
- action.note
278
+ add.note
429
279
  else
430
- overwrite ? note : action.note.concat(note)
280
+ overwrite ? note : add.note.concat(note)
431
281
  end
432
282
 
433
283
  note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"
@@ -449,9 +299,9 @@ module NA
449
299
  target_line = target_proj.line + 1
450
300
  end
451
301
 
452
- contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")
302
+ contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
453
303
 
454
- notify(action.pretty)
304
+ notify(add.pretty)
455
305
  else
456
306
  _, actions = find_actions(target, search, tagged, done: done, all: all)
457
307
 
@@ -464,12 +314,13 @@ module NA
464
314
  projects = shift_index_after(projects, action.line, action.note.count + 1)
465
315
 
466
316
  if edit
467
- new_action, new_note = format_input(fork_editor("#{action.action}\n#{action.note.join("\n")}"))
317
+ editor_content = "#{action.action}\n#{action.note.join("\n")}"
318
+ new_action, new_note = Editor.format_input(Editor.fork_editor(editor_content))
468
319
  action.action = new_action
469
320
  action.note = new_note
470
321
  end
471
322
 
472
- action = process_action(action, priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
323
+ action.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
473
324
 
474
325
  target_proj = if target_proj
475
326
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
@@ -504,7 +355,6 @@ module NA
504
355
  target_line = target_proj.line + 1
505
356
  end
506
357
 
507
-
508
358
  contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")
509
359
 
510
360
  notify(action.pretty)
@@ -560,6 +410,11 @@ module NA
560
410
  parents
561
411
  end
562
412
 
413
+ # Output an Omnifocus-friendly action list
414
+ #
415
+ # @param children The children
416
+ # @param level The indent level
417
+ #
563
418
  def output_children(children, level = 1)
564
419
  out = []
565
420
  indent = "\t" * level
@@ -595,220 +450,190 @@ module NA
595
450
  out
596
451
  end
597
452
 
453
+ def edit_file(file: nil, app: nil)
454
+ os_open(file, app: app) if file && File.exist?(file)
455
+ end
456
+
598
457
  ##
599
- ## Pretty print a list of actions
458
+ ## Use the *nix `find` command to locate files matching NA.extension
600
459
  ##
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
460
+ ## @param depth [Number] The depth at which to search
605
461
  ##
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
462
+ def find_files(depth: 1)
463
+ return [NA.global_file] if NA.global_file
639
464
 
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
465
+ files = `find . -maxdepth #{depth} -name "*.#{NA.extension}"`.strip.split("\n")
466
+ files.each { |f| save_working_dir(File.expand_path(f)) }
467
+ files
672
468
  end
673
469
 
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
470
+ def find_files_matching(options = {})
471
+ defaults = {
472
+ depth: 1,
473
+ done: false,
474
+ file_path: nil,
475
+ negate: false,
476
+ project: nil,
477
+ query: nil,
478
+ regex: false,
479
+ require_na: true,
480
+ search: nil,
481
+ tag: nil
482
+ }
483
+ opts = defaults.merge(options)
484
+
485
+ files = find_files(depth: options[:depth])
486
+
487
+ files.delete_if do |file|
488
+ todo = NA::Todo.new({
489
+ depth: options[:depth],
490
+ done: options[:done],
491
+ file_path: file,
492
+ negate: options[:negate],
493
+ project: options[:project],
494
+ query: options[:query],
495
+ regex: options[:regex],
496
+ require_na: options[:require_na],
497
+ search: options[:search],
498
+ tag: options[:tag]
499
+ })
500
+ todo.actions.length.zero?
733
501
  end
734
502
 
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
503
+ files
504
+ end
763
505
 
764
- projects.push(NA::Project.new(parent.join(':'), indent, idx, idx))
506
+ ##
507
+ ## Find a matching path using semi-fuzzy matching.
508
+ ## Search tokens can include ! and + to negate or make
509
+ ## required.
510
+ ##
511
+ ## @param search [Array] search tokens to
512
+ ## match
513
+ ## @param distance [Integer] allowed distance
514
+ ## between characters
515
+ ## @param require_last [Boolean] require regex to
516
+ ## match last element of path
517
+ ##
518
+ ## @return [Array] array of matching directories/todo files
519
+ ##
520
+ def match_working_dir(search, distance: 1, require_last: true)
521
+ file = database_path
522
+ NA.notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
765
523
 
766
- indent_level = indent
767
- elsif line.blank?
768
- in_action = false
769
- elsif line.action?
770
- in_action = false
524
+ dirs = file.read_file.split("\n")
771
525
 
772
- action = line.action
773
- new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
526
+ optional = search.filter { |s| !s[:negate] }.map { |t| t[:token] }
527
+ required = search.filter { |s| s[:required] }.map { |t| t[:token] }
528
+ negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }
774
529
 
775
- projects[-1].last_line = idx if projects.count.positive?
530
+ optional.push('*') if optional.count.zero? && required.count.zero? && negated.count.positive?
531
+ if optional == negated
532
+ required = ['*']
533
+ optional = ['*']
534
+ end
776
535
 
777
- next if line.done? && !done
536
+ NA.notify("{dw}Optional directory regex: {x}#{optional.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
537
+ NA.notify("{dw}Required directory regex: {x}#{required.map { |t| t.dir_to_rx(distance: distance) }}", debug: true)
538
+ NA.notify("{dw}Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: distance, require_last: false) }}", debug: true)
778
539
 
779
- next if require_na && !line.na?
540
+ if require_last
541
+ dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
542
+ else
543
+ dirs.delete_if do |d|
544
+ !d.sub(/\.#{NA.extension}$/, '')
545
+ .dir_matches(any: optional, all: required, none: negated, distance: 2, require_last: false)
546
+ end
547
+ end
780
548
 
781
- has_search = !optional.empty? || !required.empty? || !negated.empty?
549
+ dirs = dirs.sort.uniq
550
+ if dirs.empty? && require_last
551
+ NA.notify('{y}No matches, loosening search', debug: true)
552
+ match_working_dir(search, distance: 2, require_last: false)
553
+ else
554
+ dirs
555
+ end
556
+ end
782
557
 
783
- next if has_search && !new_action.search_match?(any: optional,
784
- all: required,
785
- none: negated)
558
+ ##
559
+ ## Save a todo file path to the database
560
+ ##
561
+ ## @param todo_file The todo file path
562
+ ##
563
+ def save_working_dir(todo_file)
564
+ file = database_path
565
+ content = File.exist?(file) ? file.read_file : ''
566
+ dirs = content.split(/\n/)
567
+ dirs.push(File.expand_path(todo_file))
568
+ dirs.sort!.uniq!
569
+ File.open(file, 'w') { |f| f.puts dirs.join("\n") }
570
+ end
786
571
 
787
- if project
788
- rx = project.split(%r{[/:]}).join('.*?/.*?')
789
- next unless parent.join('/') =~ Regexp.new(rx, Regexp::IGNORECASE)
790
- end
572
+ ##
573
+ ## Save a backed-up file to the database
574
+ ##
575
+ ## @param file The file
576
+ ##
577
+ def save_modified_file(file)
578
+ db = database_path(file: 'last_modified.txt')
579
+ file = File.expand_path(file)
580
+ files = IO.read(db).split(/\n/).map(&:strip)
581
+ files.delete(file)
582
+ files << file
583
+ File.open(db, 'w') { |f| f.puts(files.join("\n")) }
584
+ end
791
585
 
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)
586
+ ##
587
+ ## Get the last modified file from the database
588
+ ##
589
+ ## @param search The search
590
+ ##
591
+ def last_modified_file(search: nil)
592
+ db = database_path(file: 'last_modified.txt')
593
+ files = IO.read(db).split(/\n/).map(&:strip)
594
+ files.delete_if { |f| f !~ Regexp.new(search.dir_to_rx(require_last: true)) } if search
595
+ files.last
596
+ end
796
597
 
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
598
+ ##
599
+ ## Get last modified file and restore a backup
600
+ ##
601
+ ## @param search The search
602
+ ##
603
+ def restore_last_modified_file(search: nil)
604
+ file = last_modified_file(search: search)
605
+ if file
606
+ restore_modified_file(file)
607
+ else
608
+ NA.notify('{br}No matching file found')
805
609
  end
610
+ end
806
611
 
807
- [files, actions, projects]
612
+ ##
613
+ ## Restore a file from backup
614
+ ##
615
+ ## @param file The file
616
+ ##
617
+ def restore_modified_file(file)
618
+ bak_file = File.join(File.dirname(file), ".#{File.basename(file)}.bak")
619
+ if File.exist?(bak_file)
620
+ FileUtils.mv(bak_file, file)
621
+ NA.notify("{bg}Backup restored for #{file}")
622
+ else
623
+ NA.notify("{br}Backup file for #{file} not found")
624
+ end
808
625
  end
809
626
 
810
- def edit_file(file: nil, app: nil)
811
- os_open(file, app: app) if file && File.exist?(file)
627
+ ##
628
+ ## Get path to database of known todo files
629
+ ##
630
+ ## @return [String] File path
631
+ ##
632
+ def database_path(file: 'tdlist.txt')
633
+ db_dir = File.expand_path('~/.local/share/na')
634
+ # Create directory if needed
635
+ FileUtils.mkdir_p(db_dir) unless File.directory?(db_dir)
636
+ File.join(db_dir, file)
812
637
  end
813
638
 
814
639
  ##
@@ -848,7 +673,7 @@ module NA
848
673
  elsif !file_path.nil?
849
674
  [file_path]
850
675
  elsif query.nil?
851
- find_files(depth: depth)
676
+ NA.find_files(depth: depth)
852
677
  else
853
678
  match_working_dir(query)
854
679
  end
@@ -879,7 +704,7 @@ module NA
879
704
  end
880
705
 
881
706
  dirs.map! do |dir|
882
- "{xg}#{dir.sub(/^#{ENV['HOME']}/, '~').sub(%r{/([^/]+)\.#{NA.extension}$}, '/{xbw}\1{x}')}"
707
+ "{xdg}#{dir.sub(/^#{ENV['HOME']}/, '~').sub(%r{/([^/]+)\.#{NA.extension}$}, '/{xby}\1{x}')}"
883
708
  end
884
709
 
885
710
  puts NA::Color.template(dirs.join("\n"))
@@ -951,18 +776,6 @@ module NA
951
776
  NA.notify("Opened #{file} in #{editor}", exit_code: 0)
952
777
  end
953
778
 
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)
964
- end
965
-
966
779
  ##
967
780
  ## Create a backup file
968
781
  ##
@@ -972,58 +785,10 @@ module NA
972
785
  file = ".#{File.basename(target)}.bak"
973
786
  backup = File.join(File.dirname(target), file)
974
787
  FileUtils.cp(target, backup)
788
+ save_modified_file(target)
975
789
  NA.notify("{dw}Backup file created at #{backup}", debug: true)
976
790
  end
977
791
 
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
1025
- end
1026
-
1027
792
  private
1028
793
 
1029
794
  ##
@@ -1074,39 +839,6 @@ module NA
1074
839
  multiple ? res.split(/\n/) : res
1075
840
  end
1076
841
 
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
842
  ##
1111
843
  ## macOS open command
1112
844
  ##