na 1.2.34 → 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,24 +260,24 @@ 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
- NA.notify("{r}Error parsing project #{target_proj}", exit_code: 1) if target_proj.nil?
273
+ NA.notify("{r}Error parsing project #{target}", exit_code: 1) if target_proj.nil?
424
274
 
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,7 +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}")
303
+
304
+ notify(add.pretty)
453
305
  else
454
306
  _, actions = find_actions(target, search, tagged, done: done, all: all)
455
307
 
@@ -462,12 +314,13 @@ module NA
462
314
  projects = shift_index_after(projects, action.line, action.note.count + 1)
463
315
 
464
316
  if edit
465
- 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))
466
319
  action.action = new_action
467
320
  action.note = new_note
468
321
  end
469
322
 
470
- 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)
471
324
 
472
325
  target_proj = if target_proj
473
326
  projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
@@ -503,6 +356,8 @@ module NA
503
356
  end
504
357
 
505
358
  contents.insert(target_line, "#{indent}\t- #{action.action}#{note}")
359
+
360
+ notify(action.pretty)
506
361
  end
507
362
  end
508
363
 
@@ -555,6 +410,11 @@ module NA
555
410
  parents
556
411
  end
557
412
 
413
+ # Output an Omnifocus-friendly action list
414
+ #
415
+ # @param children The children
416
+ # @param level The indent level
417
+ #
558
418
  def output_children(children, level = 1)
559
419
  out = []
560
420
  indent = "\t" * level
@@ -590,217 +450,190 @@ module NA
590
450
  out
591
451
  end
592
452
 
453
+ def edit_file(file: nil, app: nil)
454
+ os_open(file, app: app) if file && File.exist?(file)
455
+ end
456
+
593
457
  ##
594
- ## Pretty print a list of actions
458
+ ## Use the *nix `find` command to locate files matching NA.extension
595
459
  ##
596
- ## @param actions [Array] The actions
597
- ## @param depth [Number] The depth
598
- ## @param files [Array] The files actions originally came from
599
- ## @param regexes [Array] The regexes used to gather actions
460
+ ## @param depth [Number] The depth at which to search
600
461
  ##
601
- def output_actions(actions, depth, files: nil, regexes: [], notes: false, nest: false, nest_projects: false)
602
- return if files.nil?
603
-
604
- if nest
605
- template = '%parent%action'
606
-
607
- parent_files = {}
608
- out = []
609
-
610
- if nest_projects
611
- actions.each do |action|
612
- if parent_files.key?(action.file)
613
- parent_files[action.file].push(action)
614
- else
615
- parent_files[action.file] = [action]
616
- end
617
- end
618
-
619
- parent_files.each do |file, acts|
620
- projects = project_hierarchy(acts)
621
- out.push("#{file.sub(%r{^./}, '').shorten_path}:")
622
- out.concat(output_children(projects, 0))
623
- end
624
- else
625
- template = '%parent%action'
626
-
627
- actions.each do |action|
628
- if parent_files.key?(action.file)
629
- parent_files[action.file].push(action)
630
- else
631
- parent_files[action.file] = [action]
632
- end
633
- end
462
+ def find_files(depth: 1)
463
+ return [NA.global_file] if NA.global_file
634
464
 
635
- parent_files.each do |k, v|
636
- out.push("#{k.sub(%r{^\./}, '')}:")
637
- v.each do |a|
638
- out.push("\t- [#{a.parent.join('/')}] #{a.action}")
639
- out.push("\t\t#{a.note.join("\n\t\t")}") unless a.note.empty?
640
- end
641
- end
642
- end
643
- NA::Pager.page out.join("\n")
644
- else
645
- template = if files.count.positive?
646
- if files.count == 1
647
- '%parent%action'
648
- else
649
- '%filename%parent%action'
650
- end
651
- elsif find_files(depth: depth).count > 1
652
- if depth > 1
653
- '%filename%parent%action'
654
- else
655
- '%project%parent%action'
656
- end
657
- else
658
- '%parent%action'
659
- end
660
- template += '%note' if notes
661
-
662
- files.map { |f| notify("{dw}#{f}", debug: true) } if files
663
-
664
- output = actions.map { |action| action.pretty(template: { output: template }, regexes: regexes, notes: notes) }
665
- NA::Pager.page(output.join("\n"))
666
- 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
667
468
  end
668
469
 
669
- ##
670
- ## Read a todo file and create a list of actions
671
- ##
672
- ## @param depth [Number] The directory depth
673
- ## to search for files
674
- ## @param done [Boolean] include @done actions
675
- ## @param query [Hash] The todo file query
676
- ## @param tag [Array] Tags to search for
677
- ## @param search [String] A search string
678
- ## @param negate [Boolean] Invert results
679
- ## @param regex [Boolean] Interpret as
680
- ## regular expression
681
- ## @param project [String] The project
682
- ## @param require_na [Boolean] Require @na tag
683
- ## @param file_path [String] file path to parse
684
- ##
685
- 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)
686
- actions = []
687
- required = []
688
- optional = []
689
- negated = []
690
- required_tag = []
691
- optional_tag = []
692
- negated_tag = []
693
- projects = []
694
-
695
- tag&.each do |t|
696
- unless t[:tag].nil?
697
- if negate
698
- optional_tag.push(t) if t[:negate]
699
- required_tag.push(t) if t[:required] && t[:negate]
700
- negated_tag.push(t) unless t[:negate]
701
- else
702
- optional_tag.push(t) unless t[:negate]
703
- required_tag.push(t) if t[:required] && !t[:negate]
704
- negated_tag.push(t) if t[:negate]
705
- end
706
- end
707
- end
708
-
709
- unless search.nil?
710
- if regex || search.is_a?(String)
711
- if negate
712
- negated.push(search)
713
- else
714
- optional.push(search)
715
- required.push(search)
716
- end
717
- else
718
- search.each do |t|
719
- opt, req, neg = parse_search(t, negate)
720
- optional.concat(opt)
721
- required.concat(req)
722
- negated.concat(neg)
723
- end
724
- 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?
725
501
  end
726
502
 
727
- files = if !file_path.nil?
728
- [file_path]
729
- elsif query.nil?
730
- find_files(depth: depth)
731
- else
732
- match_working_dir(query)
733
- end
734
-
735
- files.each do |file|
736
- save_working_dir(File.expand_path(file))
737
- content = file.read_file
738
- indent_level = 0
739
- parent = []
740
- in_action = false
741
- content.split("\n").each.with_index do |line, idx|
742
- if line.project?
743
- in_action = false
744
- proj = line.project
745
- indent = line.indent_level
746
-
747
- if indent.zero? # top level project
748
- parent = [proj]
749
- elsif indent <= indent_level # if indent level is same or less, split parent before indent level and append
750
- parent.slice!(indent, parent.count - indent)
751
- parent.push(proj)
752
- else # if indent level is greater, append project to parent
753
- parent.push(proj)
754
- end
503
+ files
504
+ end
755
505
 
756
- 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)
757
523
 
758
- indent_level = indent
759
- elsif line.blank?
760
- in_action = false
761
- elsif line.action?
762
- in_action = false
524
+ dirs = file.read_file.split("\n")
763
525
 
764
- action = line.action
765
- 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] }
766
529
 
767
- 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
768
535
 
769
- 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)
770
539
 
771
- 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
772
548
 
773
- 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
774
557
 
775
- next if has_search && !new_action.search_match?(any: optional,
776
- all: required,
777
- 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
778
571
 
779
- if project
780
- rx = project.split(%r{[/:]}).join('.*?/.*?')
781
- next unless parent.join('/') =~ Regexp.new(rx, Regexp::IGNORECASE)
782
- 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
783
585
 
784
- has_tag = !optional_tag.empty? || !required_tag.empty? || !negated_tag.empty?
785
- next if has_tag && !new_action.tags_match?(any: optional_tag,
786
- all: required_tag,
787
- 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
788
597
 
789
- actions.push(new_action)
790
- in_action = true
791
- elsif in_action
792
- actions[-1].note.push(line.strip) if actions.count.positive?
793
- projects[-1].last_line = idx if projects.count.positive?
794
- end
795
- end
796
- 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')
797
609
  end
610
+ end
798
611
 
799
- [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
800
625
  end
801
626
 
802
- def edit_file(file: nil, app: nil)
803
- 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)
804
637
  end
805
638
 
806
639
  ##
@@ -840,7 +673,7 @@ module NA
840
673
  elsif !file_path.nil?
841
674
  [file_path]
842
675
  elsif query.nil?
843
- find_files(depth: depth)
676
+ NA.find_files(depth: depth)
844
677
  else
845
678
  match_working_dir(query)
846
679
  end
@@ -871,7 +704,7 @@ module NA
871
704
  end
872
705
 
873
706
  dirs.map! do |dir|
874
- "{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}')}"
875
708
  end
876
709
 
877
710
  puts NA::Color.template(dirs.join("\n"))
@@ -943,18 +776,6 @@ module NA
943
776
  NA.notify("Opened #{file} in #{editor}", exit_code: 0)
944
777
  end
945
778
 
946
- ##
947
- ## Get path to database of known todo files
948
- ##
949
- ## @return [String] File path
950
- ##
951
- def database_path(file: 'tdlist.txt')
952
- db_dir = File.expand_path('~/.local/share/na')
953
- # Create directory if needed
954
- FileUtils.mkdir_p(db_dir) unless File.directory?(db_dir)
955
- File.join(db_dir, file)
956
- end
957
-
958
779
  ##
959
780
  ## Create a backup file
960
781
  ##
@@ -964,58 +785,10 @@ module NA
964
785
  file = ".#{File.basename(target)}.bak"
965
786
  backup = File.join(File.dirname(target), file)
966
787
  FileUtils.cp(target, backup)
788
+ save_modified_file(target)
967
789
  NA.notify("{dw}Backup file created at #{backup}", debug: true)
968
790
  end
969
791
 
970
- ##
971
- ## Find a matching path using semi-fuzzy matching.
972
- ## Search tokens can include ! and + to negate or make
973
- ## required.
974
- ##
975
- ## @param search [Array] search tokens to
976
- ## match
977
- ## @param distance [Integer] allowed distance
978
- ## between characters
979
- ## @param require_last [Boolean] require regex to
980
- ## match last element of path
981
- ##
982
- ## @return [Array] array of matching directories/todo files
983
- ##
984
- def match_working_dir(search, distance: 1, require_last: true)
985
- file = database_path
986
- notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
987
-
988
- dirs = file.read_file.split("\n")
989
-
990
- optional = search.filter { |s| !s[:negate] }.map { |t| t[:token] }
991
- required = search.filter { |s| s[:required] }.map { |t| t[:token] }
992
- negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }
993
-
994
- optional.push('*') if optional.count.zero? && required.count.zero? && negated.count.positive?
995
- if optional == negated
996
- required = ['*']
997
- optional = ['*']
998
- end
999
-
1000
- NA.notify("{dw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
1001
- NA.notify("{dw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
1002
- NA.notify("{dw}Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: 1, require_last: false) }}", debug: true)
1003
-
1004
- if require_last
1005
- dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
1006
- else
1007
- dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated, distance: 2, require_last: false) }
1008
- end
1009
-
1010
- dirs = dirs.sort.uniq
1011
- if dirs.empty? && require_last
1012
- NA.notify("{y}No matches, loosening search", debug: true)
1013
- match_working_dir(search, distance: 2, require_last: false)
1014
- else
1015
- dirs
1016
- end
1017
- end
1018
-
1019
792
  private
1020
793
 
1021
794
  ##
@@ -1066,39 +839,6 @@ module NA
1066
839
  multiple ? res.split(/\n/) : res
1067
840
  end
1068
841
 
1069
- def parse_search(tag, negate)
1070
- required = []
1071
- optional = []
1072
- negated = []
1073
- new_rx = tag[:token].to_s.wildcard_to_rx
1074
-
1075
- if negate
1076
- optional.push(new_rx) if tag[:negate]
1077
- required.push(new_rx) if tag[:required] && tag[:negate]
1078
- negated.push(new_rx) unless tag[:negate]
1079
- else
1080
- optional.push(new_rx) unless tag[:negate]
1081
- required.push(new_rx) if tag[:required] && !tag[:negate]
1082
- negated.push(new_rx) if tag[:negate]
1083
- end
1084
-
1085
- [optional, required, negated]
1086
- end
1087
-
1088
- ##
1089
- ## Save a todo file path to the database
1090
- ##
1091
- ## @param todo_file The todo file path
1092
- ##
1093
- def save_working_dir(todo_file)
1094
- file = database_path
1095
- content = File.exist?(file) ? file.read_file : ''
1096
- dirs = content.split(/\n/)
1097
- dirs.push(File.expand_path(todo_file))
1098
- dirs.sort!.uniq!
1099
- File.open(file, 'w') { |f| f.puts dirs.join("\n") }
1100
- end
1101
-
1102
842
  ##
1103
843
  ## macOS open command
1104
844
  ##