na 1.2.35 → 1.2.38
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +1 -1
- data/README.md +156 -6
- data/bin/commands/add.rb +11 -13
- data/bin/commands/complete.rb +4 -0
- data/bin/commands/edit.rb +21 -24
- data/bin/commands/find.rb +36 -23
- data/bin/commands/init.rb +1 -1
- data/bin/commands/next.rb +70 -37
- data/bin/commands/projects.rb +1 -1
- data/bin/commands/prompt.rb +2 -0
- data/bin/commands/saved.rb +3 -4
- data/bin/commands/tagged.rb +37 -30
- data/bin/commands/todos.rb +24 -14
- data/bin/commands/undo.rb +22 -0
- data/bin/commands/update.rb +28 -21
- data/bin/na +2 -1
- data/lib/na/action.rb +39 -22
- data/lib/na/actions.rb +87 -0
- data/lib/na/colors.rb +23 -1
- data/lib/na/editor.rb +125 -0
- data/lib/na/hash.rb +31 -0
- data/lib/na/next_action.rb +237 -498
- data/lib/na/pager.rb +1 -1
- data/lib/na/prompt.rb +6 -6
- data/lib/na/string.rb +23 -3
- data/lib/na/theme.rb +71 -0
- data/lib/na/todo.rb +183 -0
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +4 -0
- data/src/_README.md +45 -1
- metadata +7 -2
data/lib/na/next_action.rb
CHANGED
@@ -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
|
-
|
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("{
|
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(
|
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
|
-
|
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
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
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(
|
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
|
-
|
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("{
|
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(
|
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
|
-
|
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 =~ /^#{
|
274
|
+
projects.select { |proj| proj.project =~ /^#{add.parent.join(':')}$/ }.first
|
421
275
|
end
|
422
276
|
|
423
|
-
NA.notify("{
|
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
|
-
|
282
|
+
add.note
|
429
283
|
else
|
430
|
-
overwrite ? 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- #{
|
306
|
+
contents.insert(target_line, "#{indent}\t- #{add.action}#{note}")
|
453
307
|
|
454
|
-
notify(
|
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
|
-
|
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
|
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
|
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
|
-
##
|
465
|
+
## Use the *nix `find` command to locate files matching NA.extension
|
600
466
|
##
|
601
|
-
## @param
|
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
|
607
|
-
return if
|
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
|
-
|
641
|
-
|
642
|
-
|
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
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
|
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
|
736
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
773
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
784
|
-
|
785
|
-
|
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
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
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
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
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
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
|
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
|
-
|
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
|
-
|
811
|
-
|
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(
|
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
|
-
|
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(
|
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("{
|
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(
|
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(
|
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(%({
|
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(
|
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("{
|
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(
|
777
|
+
NA.notify("#{NA.theme[:error]}No search definitions found", exit_code: 1) unless searches.count.positive?
|
946
778
|
|
947
|
-
editor =
|
948
|
-
NA.notify(
|
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
|
-
|
976
|
-
|
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("{
|
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("{
|
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("{
|
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(
|
881
|
+
notify("#{NA.theme[:error]}Unable to determine executable for `xdg-open`.")
|
1143
882
|
end
|
1144
883
|
end
|
1145
884
|
end
|