na 1.2.35 → 1.2.37

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
  ##