na 1.1.25 → 1.2.0
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 +149 -12
- data/bin/na +355 -51
- data/lib/na/action.rb +11 -13
- data/lib/na/colors.rb +2 -1
- data/lib/na/next_action.rb +349 -50
- data/lib/na/project.rb +26 -0
- data/lib/na/string.rb +9 -5
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/src/README.md +58 -4
- metadata +19 -18
data/lib/na/next_action.rb
CHANGED
@@ -89,6 +89,7 @@ module NA
|
|
89
89
|
ENDCONTENT
|
90
90
|
f.puts(content)
|
91
91
|
end
|
92
|
+
save_working_dir(target)
|
92
93
|
notify("{y}Created {bw}#{target}")
|
93
94
|
end
|
94
95
|
|
@@ -106,24 +107,28 @@ module NA
|
|
106
107
|
##
|
107
108
|
## Select from multiple files
|
108
109
|
##
|
109
|
-
## @note
|
110
|
+
## @note If `gum` or `fzf` are available, they'll
|
111
|
+
## be used (in that order)
|
110
112
|
##
|
111
|
-
## @param files
|
113
|
+
## @param files [Array] The files
|
114
|
+
## @param multiple [Boolean] allow multiple selections
|
112
115
|
##
|
113
|
-
def select_file(files)
|
114
|
-
if TTY::Which.exist?('
|
115
|
-
|
116
|
-
'--cursor.foreground="151"',
|
117
|
-
'--item.foreground=""'
|
118
|
-
]
|
119
|
-
`echo #{Shellwords.escape(files.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`.strip
|
120
|
-
elsif TTY::Which.exist?('fzf')
|
121
|
-
res = choose_from(files, prompt: 'Use which file?')
|
116
|
+
def select_file(files, multiple: false)
|
117
|
+
if TTY::Which.exist?('fzf')
|
118
|
+
res = choose_from(files, prompt: 'Use which file?', multiple: multiple)
|
122
119
|
unless res
|
123
120
|
notify('{r}No file selected, cancelled', exit_code: 1)
|
124
121
|
end
|
125
122
|
|
126
|
-
res.strip
|
123
|
+
multiple ? res.split("\n") : res.strip
|
124
|
+
elsif TTY::Which.exist?('gum')
|
125
|
+
args = [
|
126
|
+
'--cursor.foreground="151"',
|
127
|
+
'--item.foreground=""'
|
128
|
+
]
|
129
|
+
args.push('--no-limit') if multiple
|
130
|
+
res = `echo #{Shellwords.escape(files.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`
|
131
|
+
multiple ? res.split("\n") : res.strip
|
127
132
|
else
|
128
133
|
reader = TTY::Reader.new
|
129
134
|
puts
|
@@ -135,6 +140,195 @@ module NA
|
|
135
140
|
end
|
136
141
|
end
|
137
142
|
|
143
|
+
def shift_index_after(projects, idx, length = 1)
|
144
|
+
projects.map do |proj|
|
145
|
+
proj.line = proj.line > idx ? proj.line - length : proj.line
|
146
|
+
proj
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def find_projects(target)
|
151
|
+
_, _, projects = parse_actions(require_na: false, file_path: target)
|
152
|
+
projects
|
153
|
+
end
|
154
|
+
|
155
|
+
def find_actions(target, search, tagged = nil, all: false)
|
156
|
+
_, actions, projects = parse_actions(search: search, require_na: false, file_path: target, tag: tagged)
|
157
|
+
|
158
|
+
NA.notify('{r}No matching actions found', exit_code: 1) unless actions.count.positive?
|
159
|
+
return [projects, actions] if actions.count == 1 || all
|
160
|
+
|
161
|
+
options = actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
|
162
|
+
res = if TTY::Which.exist?('fzf')
|
163
|
+
choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
|
164
|
+
elsif TTY::Which.exist?('gum')
|
165
|
+
args = [
|
166
|
+
'--cursor.foreground="151"',
|
167
|
+
'--item.foreground=""',
|
168
|
+
'--no-limit'
|
169
|
+
]
|
170
|
+
`echo #{Shellwords.escape(options.join("\n"))}|#{TTY::Which.which('gum')} choose #{args.join(' ')}`.strip
|
171
|
+
else
|
172
|
+
reader = TTY::Reader.new
|
173
|
+
puts
|
174
|
+
options.each.with_index do |f, i|
|
175
|
+
puts NA::Color.template(format("{bw}%<idx> 2d{xw}) {y}%<action>s{x}\n", idx: i + 1, action: f))
|
176
|
+
end
|
177
|
+
result = reader.read_line(NA::Color.template('{bw}Use which file? {x}')).strip
|
178
|
+
if result && result.to_i.positive?
|
179
|
+
options[result.to_i - 1]
|
180
|
+
else
|
181
|
+
nil
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
NA.notify('{r}Cancelled', exit_code: 1) unless res && res.length.positive?
|
186
|
+
|
187
|
+
selected = []
|
188
|
+
res.split(/\n/).each do |result|
|
189
|
+
idx = result.match(/^(\d+)(?= % )/)[1]
|
190
|
+
action = actions.select { |a| a.line == idx.to_i }.first
|
191
|
+
selected.push(action)
|
192
|
+
end
|
193
|
+
[projects, selected]
|
194
|
+
end
|
195
|
+
|
196
|
+
def insert_project(target, project)
|
197
|
+
path = project.split(%r{[:/]})
|
198
|
+
_, _, projects = parse_actions(file_path: target)
|
199
|
+
built = []
|
200
|
+
last_match = nil
|
201
|
+
final_match = nil
|
202
|
+
new_path = []
|
203
|
+
matches = nil
|
204
|
+
path.each_with_index do |part, i|
|
205
|
+
built.push(part)
|
206
|
+
matches = projects.select { |proj| proj.project =~ /^#{built.join(':')}/i }
|
207
|
+
if matches.count.zero?
|
208
|
+
final_match = last_match
|
209
|
+
new_path = path.slice(i, path.count - i)
|
210
|
+
break
|
211
|
+
else
|
212
|
+
last_match = matches.last
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
content = target.read_file
|
217
|
+
if final_match.nil?
|
218
|
+
indent = 0
|
219
|
+
input = []
|
220
|
+
new_path.each do |part|
|
221
|
+
input.push("#{"\t" * indent}#{part.cap_first}:")
|
222
|
+
indent += 1
|
223
|
+
end
|
224
|
+
|
225
|
+
if new_path.join('') =~ /Archive/i
|
226
|
+
content = "#{content.strip}\n#{input.join("\n")}"
|
227
|
+
else
|
228
|
+
content = "#{input.join("\n")}\n#{content}"
|
229
|
+
end
|
230
|
+
|
231
|
+
new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, input.count - 1)
|
232
|
+
else
|
233
|
+
line = final_match.line + 1
|
234
|
+
indent = final_match.indent + 1
|
235
|
+
input = []
|
236
|
+
new_path.each do |part|
|
237
|
+
input.push("#{"\t" * indent}#{part.cap_first}:")
|
238
|
+
indent += 1
|
239
|
+
end
|
240
|
+
content = content.split("\n").insert(line, input.join("\n")).join("\n")
|
241
|
+
new_project = NA::Project.new(path.map(&:cap_first).join(':'), indent - 1, line + input.count - 1)
|
242
|
+
end
|
243
|
+
|
244
|
+
File.open(target, 'w') do |f|
|
245
|
+
f.puts content
|
246
|
+
end
|
247
|
+
|
248
|
+
new_project
|
249
|
+
end
|
250
|
+
|
251
|
+
def update_action(target,
|
252
|
+
search,
|
253
|
+
priority: 0,
|
254
|
+
add_tag: [],
|
255
|
+
remove_tag: [],
|
256
|
+
finish: false,
|
257
|
+
project: nil,
|
258
|
+
delete: false,
|
259
|
+
note: [],
|
260
|
+
overwrite: false,
|
261
|
+
tagged: nil,
|
262
|
+
all: false)
|
263
|
+
|
264
|
+
projects = find_projects(target)
|
265
|
+
|
266
|
+
target_proj = nil
|
267
|
+
|
268
|
+
if project
|
269
|
+
target_proj = projects.select { |pr| pr.project =~ /#{project.gsub(/:/, '.*?:.*?')}/i }.first
|
270
|
+
if target_proj.nil?
|
271
|
+
res = NA.yn(NA::Color.template("{y}Project {bw}#{project}{xy} doesn't exist, add it"), default: true)
|
272
|
+
if res
|
273
|
+
target_proj = insert_project(target, project)
|
274
|
+
else
|
275
|
+
NA.notify('{x}Cancelled', exit_code: 1)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
projects, actions = find_actions(target, search, tagged, all: all)
|
281
|
+
|
282
|
+
contents = target.read_file.split(/\n/)
|
283
|
+
|
284
|
+
actions.sort_by(&:line).reverse.each do |action|
|
285
|
+
string = action.action
|
286
|
+
|
287
|
+
if priority&.positive?
|
288
|
+
string.gsub!(/@priority\(\d+\)/, '').strip!
|
289
|
+
string += " @priority(#{priority})"
|
290
|
+
end
|
291
|
+
|
292
|
+
add_tag.each do |tag|
|
293
|
+
string.gsub!(/@#{tag.gsub(/([()*?])/, '\\\\1')}(\(.*?\))?/, '')
|
294
|
+
string.strip!
|
295
|
+
string += " @#{tag}"
|
296
|
+
end
|
297
|
+
|
298
|
+
remove_tag.each do |tag|
|
299
|
+
string.gsub!(/@#{tag}(\(.*?\))?/, '')
|
300
|
+
string.strip!
|
301
|
+
end
|
302
|
+
|
303
|
+
string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /@done/
|
304
|
+
|
305
|
+
contents.slice!(action.line, action.note.count + 1)
|
306
|
+
next if delete
|
307
|
+
|
308
|
+
projects = shift_index_after(projects, action.line, action.note.count + 1)
|
309
|
+
|
310
|
+
target_proj = if target_proj
|
311
|
+
projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
|
312
|
+
else
|
313
|
+
projects.select { |proj| proj.project =~ /^#{action.parent.join(':')}$/ }.first
|
314
|
+
end
|
315
|
+
|
316
|
+
indent = "\t" * target_proj.indent
|
317
|
+
note = note.split("\n") unless note.is_a?(Array)
|
318
|
+
note = if note.empty?
|
319
|
+
action.note
|
320
|
+
else
|
321
|
+
overwrite ? note : action.note.concat(note)
|
322
|
+
end
|
323
|
+
note = note.empty? ? '' : "\n#{indent}\t\t#{note.join("\n#{indent}\t\t").strip}"
|
324
|
+
contents.insert(target_proj.line, "#{indent}\t- #{string}#{note}")
|
325
|
+
end
|
326
|
+
backup_file(target)
|
327
|
+
File.open(target, 'w') { |f| f.puts contents.join("\n") }
|
328
|
+
|
329
|
+
notify("{by}Task updated in {bw}#{target}")
|
330
|
+
end
|
331
|
+
|
138
332
|
##
|
139
333
|
## Add an action to a todo file
|
140
334
|
##
|
@@ -153,7 +347,8 @@ module NA
|
|
153
347
|
# Insert the action at the top of the target project
|
154
348
|
content.sub!(/^([ \t]*)#{project}:(.*?)$/i) do
|
155
349
|
m = Regexp.last_match
|
156
|
-
|
350
|
+
indent = "\n#{m[1]}\t\t"
|
351
|
+
note = note.nil? ? '' : "#{indent}#{note.join(indent).strip}"
|
157
352
|
"#{m[1]}#{project.cap_first}:#{m[2]}\n#{m[1]}\t- #{action}#{note}"
|
158
353
|
end
|
159
354
|
|
@@ -197,16 +392,19 @@ module NA
|
|
197
392
|
##
|
198
393
|
## Read a todo file and create a list of actions
|
199
394
|
##
|
200
|
-
## @param depth [Number] The directory depth
|
201
|
-
##
|
202
|
-
## @param
|
395
|
+
## @param depth [Number] The directory depth
|
396
|
+
## to search for files
|
397
|
+
## @param query [Hash] The todo file query
|
398
|
+
## @param tag [Array] Tags to search for
|
203
399
|
## @param search [String] A search string
|
204
400
|
## @param negate [Boolean] Invert results
|
205
|
-
## @param regex [Boolean] Interpret as
|
401
|
+
## @param regex [Boolean] Interpret as
|
402
|
+
## regular expression
|
206
403
|
## @param project [String] The project
|
207
404
|
## @param require_na [Boolean] Require @na tag
|
405
|
+
## @param file [String] file path to parse
|
208
406
|
##
|
209
|
-
def parse_actions(depth: 1, query: nil, tag: nil, search: nil, negate: false, regex: false, project: nil, require_na: true)
|
407
|
+
def parse_actions(depth: 1, query: nil, tag: nil, search: nil, negate: false, regex: false, project: nil, require_na: true, file_path: nil)
|
210
408
|
actions = []
|
211
409
|
required = []
|
212
410
|
optional = []
|
@@ -214,6 +412,7 @@ module NA
|
|
214
412
|
required_tag = []
|
215
413
|
optional_tag = []
|
216
414
|
negated_tag = []
|
415
|
+
projects = []
|
217
416
|
|
218
417
|
tag&.each do |t|
|
219
418
|
unless t[:tag].nil?
|
@@ -247,7 +446,9 @@ module NA
|
|
247
446
|
end
|
248
447
|
end
|
249
448
|
|
250
|
-
files = if
|
449
|
+
files = if !file_path.nil?
|
450
|
+
[file_path]
|
451
|
+
elsif query.nil?
|
251
452
|
find_files(depth: depth)
|
252
453
|
else
|
253
454
|
match_working_dir(query)
|
@@ -258,8 +459,10 @@ module NA
|
|
258
459
|
content = file.read_file
|
259
460
|
indent_level = 0
|
260
461
|
parent = []
|
261
|
-
|
262
|
-
|
462
|
+
in_action = false
|
463
|
+
content.split("\n").each.with_index do |line, idx|
|
464
|
+
if line =~ /^([ \t]*)([^\-@()]+?): *(@\S+ *)*$/
|
465
|
+
in_action = false
|
263
466
|
proj = Regexp.last_match(2)
|
264
467
|
indent = line.indent_level
|
265
468
|
|
@@ -272,14 +475,22 @@ module NA
|
|
272
475
|
parent.push(proj)
|
273
476
|
end
|
274
477
|
|
478
|
+
projects.push(NA::Project.new(parent.join(':'), indent, idx + 1))
|
479
|
+
|
275
480
|
indent_level = indent
|
276
|
-
elsif line =~ /^[ \t]*- /
|
481
|
+
elsif line =~ /^[ \t]*- /
|
482
|
+
in_action = false
|
483
|
+
# search_for_done = false
|
484
|
+
# optional_tag.each { |t| search_for_done = true if t[:tag] =~ /done/ }
|
485
|
+
# next if line =~ /@done/ && !search_for_done
|
486
|
+
|
277
487
|
next if require_na && line !~ /@#{NA.na_tag}\b/
|
278
488
|
|
279
489
|
action = line.sub(/^[ \t]*- /, '')
|
280
|
-
new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action)
|
490
|
+
new_action = NA::Action.new(file, File.basename(file, ".#{NA.extension}"), parent.dup, action, idx)
|
281
491
|
|
282
492
|
has_search = !optional.empty? || !required.empty? || !negated.empty?
|
493
|
+
|
283
494
|
next if has_search && !new_action.search_match?(any: optional,
|
284
495
|
all: required,
|
285
496
|
none: negated)
|
@@ -295,10 +506,14 @@ module NA
|
|
295
506
|
none: negated_tag)
|
296
507
|
|
297
508
|
actions.push(new_action)
|
509
|
+
in_action = true
|
510
|
+
else
|
511
|
+
actions[-1].note.push(line.strip) if actions.count.positive? && in_action
|
298
512
|
end
|
299
513
|
end
|
300
514
|
end
|
301
|
-
|
515
|
+
|
516
|
+
[files, actions, projects]
|
302
517
|
end
|
303
518
|
|
304
519
|
def edit_file(file: nil, app: nil)
|
@@ -336,9 +551,32 @@ module NA
|
|
336
551
|
end
|
337
552
|
end
|
338
553
|
|
554
|
+
def list_projects(query: [], file_path: nil, depth: 1, paths: true)
|
555
|
+
files = if !file_path.nil?
|
556
|
+
[file_path]
|
557
|
+
elsif query.nil?
|
558
|
+
find_files(depth: depth)
|
559
|
+
else
|
560
|
+
match_working_dir(query)
|
561
|
+
end
|
562
|
+
target = files.count > 1 ? NA.select_file(files) : files[0]
|
563
|
+
projects = find_projects(target)
|
564
|
+
projects.each do |proj|
|
565
|
+
parts = proj.project.split(/:/)
|
566
|
+
output = if paths
|
567
|
+
"{bg}#{parts.join('{bw}/{bg}')}{x}"
|
568
|
+
else
|
569
|
+
parts.fill("{bw}—{bg}", 0..-2)
|
570
|
+
"{bg}#{parts.join(' ')}{x}"
|
571
|
+
end
|
572
|
+
|
573
|
+
puts NA::Color.template(output)
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
339
577
|
def list_todos(query: [])
|
340
578
|
if query
|
341
|
-
dirs = match_working_dir(query)
|
579
|
+
dirs = match_working_dir(query, distance: 2, require_last: false)
|
342
580
|
else
|
343
581
|
file = database_path
|
344
582
|
content = File.exist?(file) ? file.read_file.strip : ''
|
@@ -386,6 +624,40 @@ module NA
|
|
386
624
|
searches
|
387
625
|
end
|
388
626
|
|
627
|
+
def delete_search(strings = nil)
|
628
|
+
NA.notify('{r}Name search required', exit_code: 1) if strings.nil? || strings.empty?
|
629
|
+
|
630
|
+
file = database_path(file: 'saved_searches.yml')
|
631
|
+
NA.notify('{r}No search definitions file found', exit_code: 1) unless File.exist?(file)
|
632
|
+
|
633
|
+
searches = YAML.safe_load(file.read_file)
|
634
|
+
keys = searches.keys.delete_if { |k| k !~ /(#{strings.join('|')})/ }
|
635
|
+
|
636
|
+
res = yn(NA::Color.template(%({y}Remove #{keys.count > 1 ? 'searches' : 'search'} {bw}"#{keys.join(', ')}"{x})),
|
637
|
+
default: false)
|
638
|
+
|
639
|
+
NA.notify('{r}Cancelled', exit_code: 1) unless res
|
640
|
+
|
641
|
+
searches.delete_if { |k| keys.include?(k) }
|
642
|
+
|
643
|
+
File.open(file, 'w') { |f| f.puts(YAML.dump(searches)) }
|
644
|
+
|
645
|
+
NA.notify("{y}Deleted {bw}#{keys.count}{xy} #{keys.count > 1 ? 'searches' : 'search'}", exit_code: 0)
|
646
|
+
end
|
647
|
+
|
648
|
+
def edit_searches
|
649
|
+
file = database_path(file: 'saved_searches.yml')
|
650
|
+
searches = load_searches
|
651
|
+
|
652
|
+
NA.notify('{r}No search definitions found', exit_code: 1) unless searches.count.positive?
|
653
|
+
|
654
|
+
editor = ENV['EDITOR']
|
655
|
+
NA.notify('{r}No $EDITOR defined', exit_code: 1) unless editor && TTY::Which.exist?(editor)
|
656
|
+
|
657
|
+
system %(#{editor} "#{file}")
|
658
|
+
NA.notify("Opened #{file} in #{editor}", exit_code: 0)
|
659
|
+
end
|
660
|
+
|
389
661
|
##
|
390
662
|
## Get path to database of known todo files
|
391
663
|
##
|
@@ -398,6 +670,58 @@ module NA
|
|
398
670
|
File.join(db_dir, file)
|
399
671
|
end
|
400
672
|
|
673
|
+
##
|
674
|
+
## Create a backup file
|
675
|
+
##
|
676
|
+
## @param target [String] The file to back up
|
677
|
+
##
|
678
|
+
def backup_file(target)
|
679
|
+
FileUtils.cp(target, "#{target}~")
|
680
|
+
end
|
681
|
+
|
682
|
+
##
|
683
|
+
## Find a matching path using semi-fuzzy matching.
|
684
|
+
## Search tokens can include ! and + to negate or make
|
685
|
+
## required.
|
686
|
+
##
|
687
|
+
## @param search [Array] search tokens to
|
688
|
+
## match
|
689
|
+
## @param distance [Integer] allowed distance
|
690
|
+
## between characters
|
691
|
+
## @param require_last [Boolean] require regex to
|
692
|
+
## match last element of path
|
693
|
+
##
|
694
|
+
## @return [Array] array of matching directories/todo files
|
695
|
+
##
|
696
|
+
def match_working_dir(search, distance: 1, require_last: true)
|
697
|
+
file = database_path
|
698
|
+
notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
|
699
|
+
|
700
|
+
dirs = file.read_file.split("\n")
|
701
|
+
|
702
|
+
optional = search.map { |t| t[:token] }
|
703
|
+
required = search.filter { |s| s[:required] }.map { |t| t[:token] }
|
704
|
+
negated = search.filter { |s| s[:negate] }.map { |t| t[:token] }
|
705
|
+
|
706
|
+
NA.notify("{dw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
|
707
|
+
NA.notify("{dw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
|
708
|
+
NA.notify("{dw}Negated directory regex: {x}#{negated.map { |t| t.dir_to_rx(distance: 1, require_last: false) }}", debug: true)
|
709
|
+
|
710
|
+
if require_last
|
711
|
+
dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated) }
|
712
|
+
else
|
713
|
+
dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required, none: negated, distance: 2, require_last: false) }
|
714
|
+
end
|
715
|
+
|
716
|
+
dirs = dirs.sort.uniq
|
717
|
+
if dirs.empty? && require_last
|
718
|
+
NA.notify("{y}No matches, loosening search", debug: true)
|
719
|
+
match_working_dir(search, distance: 2, require_last: false)
|
720
|
+
else
|
721
|
+
dirs
|
722
|
+
end
|
723
|
+
end
|
724
|
+
|
401
725
|
private
|
402
726
|
|
403
727
|
##
|
@@ -446,31 +770,6 @@ module NA
|
|
446
770
|
[optional, required, negated]
|
447
771
|
end
|
448
772
|
|
449
|
-
##
|
450
|
-
## Find a matching path using semi-fuzzy matching.
|
451
|
-
## Search tokens can include ! and + to negate or make
|
452
|
-
## required.
|
453
|
-
##
|
454
|
-
## @param search [Array] search tokens to match
|
455
|
-
## @param distance [Integer] allowed distance
|
456
|
-
## between characters
|
457
|
-
##
|
458
|
-
def match_working_dir(search, distance: 1)
|
459
|
-
file = database_path
|
460
|
-
notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
|
461
|
-
|
462
|
-
dirs = file.read_file.split("\n")
|
463
|
-
|
464
|
-
optional = search.map { |t| t[:token] }
|
465
|
-
required = search.filter { |s| s[:required] }.map { |t| t[:token] }
|
466
|
-
|
467
|
-
NA.notify("{bw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
|
468
|
-
NA.notify("{bw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
|
469
|
-
|
470
|
-
dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required) }
|
471
|
-
dirs.sort.uniq
|
472
|
-
end
|
473
|
-
|
474
773
|
##
|
475
774
|
## Save a todo file path to the database
|
476
775
|
##
|
data/lib/na/project.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NA
|
4
|
+
class Project < Hash
|
5
|
+
attr_accessor :project, :indent, :line
|
6
|
+
|
7
|
+
def initialize(project, indent = 0, line = 0)
|
8
|
+
super()
|
9
|
+
@project = project
|
10
|
+
@indent = indent
|
11
|
+
@line = line
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
{ project: @project, indent: @indent, line: @line }.to_s
|
16
|
+
end
|
17
|
+
|
18
|
+
def inspect
|
19
|
+
[
|
20
|
+
"@project: #{@project}",
|
21
|
+
"@indent: #{@indent}",
|
22
|
+
"@line: #{@line}"
|
23
|
+
].join("\n")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
data/lib/na/string.rb
CHANGED
@@ -94,14 +94,18 @@ class ::String
|
|
94
94
|
## slashes and requires that last segment
|
95
95
|
## match last segment of target path
|
96
96
|
##
|
97
|
-
## @param distance
|
97
|
+
## @param distance The distance allowed between characters
|
98
|
+
## @param require_last Require match to be last element in path
|
98
99
|
##
|
99
|
-
def dir_to_rx(distance:
|
100
|
-
"#{split(%r{[/:]}).map { |comp| comp.split('').join(".{0,#{distance}}").gsub(/\*/, '[^ ]*?') }.join('.*?/.*?')}[^/]*?$"
|
100
|
+
def dir_to_rx(distance: 1, require_last: true)
|
101
|
+
"#{split(%r{[/:]}).map { |comp| comp.split('').join(".{0,#{distance}}").gsub(/\*/, '[^ ]*?') }.join('.*?/.*?')}#{require_last ? '[^/]*?$' : ''}"
|
101
102
|
end
|
102
103
|
|
103
|
-
def dir_matches(any: [], all: [])
|
104
|
-
|
104
|
+
def dir_matches(any: [], all: [], none: [], require_last: true, distance: 1)
|
105
|
+
any_rx = any.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
|
106
|
+
all_rx = all.map { |q| q.dir_to_rx(distance: distance, require_last: require_last) }
|
107
|
+
none_rx = none.map { |q| q.dir_to_rx(distance: distance, require_last: false) }
|
108
|
+
matches_any(any_rx) && matches_all(all_rx) && matches_none(none_rx)
|
105
109
|
end
|
106
110
|
|
107
111
|
def matches(any: [], all: [], none: [])
|
data/lib/na/version.rb
CHANGED
data/lib/na.rb
CHANGED
data/src/README.md
CHANGED
@@ -9,7 +9,7 @@
|
|
9
9
|
_If you're one of the rare people like me who find this useful, feel free to
|
10
10
|
[buy me some coffee][donate]._
|
11
11
|
|
12
|
-
The current version of `na` is <!--VER-->1.1.
|
12
|
+
The current version of `na` is <!--VER-->1.1.26<!--END VER-->.
|
13
13
|
|
14
14
|
`na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
|
15
15
|
|
@@ -100,16 +100,70 @@ Examples:
|
|
100
100
|
@cli(bundle exec bin/na help next)
|
101
101
|
```
|
102
102
|
|
103
|
+
##### projects
|
104
|
+
|
105
|
+
List all projects in a file. If arguments are provided, they're used to match a todo file from history, otherwise the todo file(s) in the current directory will be used.
|
106
|
+
|
107
|
+
```
|
108
|
+
@cli(bundle exec bin/na help projects)
|
109
|
+
```
|
110
|
+
|
111
|
+
##### saved
|
112
|
+
|
113
|
+
The saved command runs saved searches. To save a search, add `--save SEARCH_NAME` to a `find` or `tagged` command. The arguments provided on the command line will be saved to a search file (`/.local/share/na/saved_searches.yml`), with the search named with the SEARCH_NAME parameter. You can then run the search again with `na saved SEARCH_NAME`. Repeating the SEARCH_NAME with a new `find/tagged` command will overwrite the previous definition.
|
114
|
+
|
115
|
+
Search names can be partially matched when calling them, so if you have a search named "overdue," you can match it with `na saved over` (shortest match will be used).
|
116
|
+
|
117
|
+
Run `na saved` without an argument to list your saved searches.
|
118
|
+
|
119
|
+
```
|
120
|
+
@cli(bundle exec bin/na help saved)
|
121
|
+
```
|
122
|
+
|
103
123
|
##### tagged
|
104
124
|
|
105
125
|
Example: `na tagged feature +maybe`.
|
106
126
|
|
107
|
-
Separate multiple tags with
|
127
|
+
Separate multiple tags/value comparisons with commas. By default tags are combined with AND, so actions matching all of the tags listed will be displayed. Use `+` to make a tag required and `!` to negate a tag (only display if the action does _not_ contain the tag). When `+` and/or `!` are used, undecorated tokens become optional matches. Use `-v` to invert the search and display all actions that _don't_ match.
|
128
|
+
|
129
|
+
You can also perform value comparisons on tags. A value in a TaskPaper tag is added by including it in parenthesis after the tag, e.g. `@due(2022-10-10 05:00)`. You can perform numeric comparisons with `<`, `>`, `<=`, `>=`, `==`, and `!=`. If comparing to a date, you can use natural language, e.g. `na tagged "due<today"`.
|
130
|
+
|
131
|
+
To perform a string comparison, you can use `*=` (contains), `^=` (starts with), `$=` (ends with), or `=` (matches). E.g. `na tagged "note*=video"`.
|
108
132
|
|
109
133
|
```
|
110
134
|
@cli(bundle exec bin/na help show)
|
111
135
|
```
|
112
136
|
|
137
|
+
##### todos
|
138
|
+
|
139
|
+
List all known todo files from history.
|
140
|
+
|
141
|
+
```
|
142
|
+
@cli(bundle exec bin/na help todos)
|
143
|
+
```
|
144
|
+
|
145
|
+
##### update
|
146
|
+
|
147
|
+
Example: `na update --in na --archive my cool action`
|
148
|
+
|
149
|
+
The above will locate a todo file matching "na" in todo history, find any action matching "my cool action", add a dated @done tag and move it to the Archive project, creating it if needed. If multiple actions are matched, a menu is presented (multi-select if fzf is available).
|
150
|
+
|
151
|
+
This command will perform actions (tag, untag, complete, archive, add note, etc.) on existing actions by matching your search text. Arguments will be interpreted as search tokens similar to `na find`. You can use `--exact` and `--regex`, as well as wildcards in the search string. You can also use `--tagged TAG_QUERY` in addition to or instead of a search query.
|
152
|
+
|
153
|
+
You can specify a particular todo file using `--file PATH` or any todo from history using `--in QUERY`.
|
154
|
+
|
155
|
+
If more than one file is matched, a menu will be presented, multiple selections allowed. If multiple actions match the search within the selected file(s), a menu will be presented. If you have fzf installed, you can select one action to update with return, or use tab to mark multiple tasks to which the action will be applied. With gum you can use j, k, and x to mark multiple actions. Use the `--all` switch to force operation on all matched tasks, skipping the menu.
|
156
|
+
|
157
|
+
Any time an update action is carried out, a backup of the file before modification will be made in the same directory with a `~` appended to the file extension (e.g. "marked.taskpaper" is backed up to "marked.taskpaper~"). Only one undo step is available, but if something goes wrong (and this feature is still experimental, so be wary), you can just copy the "~" file back to the original.
|
158
|
+
|
159
|
+
You can specify a new project for an action (moving it) with `--proj PROJECT_PATH`. A project path is hierarchical, with each level separated by a colon or slash. If the project path provided roughly matches an existing project, e.g. "mark:bug" would match "Marked:Bugs", then that project will be used. If no match is found, na will offer to generate a new project/hierarchy for the path provided. Strings will be exact but the first letter will be uppercased.
|
160
|
+
|
161
|
+
See the help output for a list of available actions.
|
162
|
+
|
163
|
+
```
|
164
|
+
@cli(bundle exec bin/na help update)
|
165
|
+
```
|
166
|
+
|
113
167
|
### Configuration
|
114
168
|
|
115
169
|
Global options such as todo extension and default next action tag can be stored permanently by using the `na initconfig` command. Run na with the global options you'd like to set, and add `initconfig` at the end of the command. A file will be written to `~/.na.rc`. You can edit this manually, or just update it using the `initconfig --force` command to overwrite it with new settings.
|
@@ -148,11 +202,11 @@ You can add a prompt command to your shell to have na automatically list your ne
|
|
148
202
|
|
149
203
|
After installing a hook, you'll need to close your terminal and start a new session to initialize the new commands.
|
150
204
|
|
151
|
-
|
152
205
|
### Misc
|
153
206
|
|
154
|
-
If you have [gum][] installed, na will use it for command line input when adding tasks and notes.
|
207
|
+
If you have [gum][] installed, na will use it for command line input when adding tasks and notes. If you have [fzf][] installed, it will be used for menus, falling back to gum if available.
|
155
208
|
|
209
|
+
[fzf]: https://github.com/junegunn/fzf
|
156
210
|
[gum]: https://github.com/charmbracelet/gum
|
157
211
|
[donate]: http://brettterpstra.com/donate/
|
158
212
|
[github]: https://github.com/ttscoff/na_gem/
|