na 1.1.26 → 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 +41 -0
- data/Gemfile.lock +1 -1
- data/README.md +146 -12
- data/bin/na +332 -41
- data/lib/na/action.rb +11 -13
- data/lib/na/colors.rb +2 -1
- data/lib/na/next_action.rb +347 -49
- 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 +3 -2
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
|
##
|
@@ -198,16 +392,19 @@ module NA
|
|
198
392
|
##
|
199
393
|
## Read a todo file and create a list of actions
|
200
394
|
##
|
201
|
-
## @param depth [Number] The directory depth
|
202
|
-
##
|
203
|
-
## @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
|
204
399
|
## @param search [String] A search string
|
205
400
|
## @param negate [Boolean] Invert results
|
206
|
-
## @param regex [Boolean] Interpret as
|
401
|
+
## @param regex [Boolean] Interpret as
|
402
|
+
## regular expression
|
207
403
|
## @param project [String] The project
|
208
404
|
## @param require_na [Boolean] Require @na tag
|
405
|
+
## @param file [String] file path to parse
|
209
406
|
##
|
210
|
-
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)
|
211
408
|
actions = []
|
212
409
|
required = []
|
213
410
|
optional = []
|
@@ -215,6 +412,7 @@ module NA
|
|
215
412
|
required_tag = []
|
216
413
|
optional_tag = []
|
217
414
|
negated_tag = []
|
415
|
+
projects = []
|
218
416
|
|
219
417
|
tag&.each do |t|
|
220
418
|
unless t[:tag].nil?
|
@@ -248,7 +446,9 @@ module NA
|
|
248
446
|
end
|
249
447
|
end
|
250
448
|
|
251
|
-
files = if
|
449
|
+
files = if !file_path.nil?
|
450
|
+
[file_path]
|
451
|
+
elsif query.nil?
|
252
452
|
find_files(depth: depth)
|
253
453
|
else
|
254
454
|
match_working_dir(query)
|
@@ -259,8 +459,10 @@ module NA
|
|
259
459
|
content = file.read_file
|
260
460
|
indent_level = 0
|
261
461
|
parent = []
|
262
|
-
|
263
|
-
|
462
|
+
in_action = false
|
463
|
+
content.split("\n").each.with_index do |line, idx|
|
464
|
+
if line =~ /^([ \t]*)([^\-@()]+?): *(@\S+ *)*$/
|
465
|
+
in_action = false
|
264
466
|
proj = Regexp.last_match(2)
|
265
467
|
indent = line.indent_level
|
266
468
|
|
@@ -273,14 +475,22 @@ module NA
|
|
273
475
|
parent.push(proj)
|
274
476
|
end
|
275
477
|
|
478
|
+
projects.push(NA::Project.new(parent.join(':'), indent, idx + 1))
|
479
|
+
|
276
480
|
indent_level = indent
|
277
|
-
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
|
+
|
278
487
|
next if require_na && line !~ /@#{NA.na_tag}\b/
|
279
488
|
|
280
489
|
action = line.sub(/^[ \t]*- /, '')
|
281
|
-
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)
|
282
491
|
|
283
492
|
has_search = !optional.empty? || !required.empty? || !negated.empty?
|
493
|
+
|
284
494
|
next if has_search && !new_action.search_match?(any: optional,
|
285
495
|
all: required,
|
286
496
|
none: negated)
|
@@ -296,10 +506,14 @@ module NA
|
|
296
506
|
none: negated_tag)
|
297
507
|
|
298
508
|
actions.push(new_action)
|
509
|
+
in_action = true
|
510
|
+
else
|
511
|
+
actions[-1].note.push(line.strip) if actions.count.positive? && in_action
|
299
512
|
end
|
300
513
|
end
|
301
514
|
end
|
302
|
-
|
515
|
+
|
516
|
+
[files, actions, projects]
|
303
517
|
end
|
304
518
|
|
305
519
|
def edit_file(file: nil, app: nil)
|
@@ -337,9 +551,32 @@ module NA
|
|
337
551
|
end
|
338
552
|
end
|
339
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
|
+
|
340
577
|
def list_todos(query: [])
|
341
578
|
if query
|
342
|
-
dirs = match_working_dir(query)
|
579
|
+
dirs = match_working_dir(query, distance: 2, require_last: false)
|
343
580
|
else
|
344
581
|
file = database_path
|
345
582
|
content = File.exist?(file) ? file.read_file.strip : ''
|
@@ -387,6 +624,40 @@ module NA
|
|
387
624
|
searches
|
388
625
|
end
|
389
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
|
+
|
390
661
|
##
|
391
662
|
## Get path to database of known todo files
|
392
663
|
##
|
@@ -399,6 +670,58 @@ module NA
|
|
399
670
|
File.join(db_dir, file)
|
400
671
|
end
|
401
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
|
+
|
402
725
|
private
|
403
726
|
|
404
727
|
##
|
@@ -447,31 +770,6 @@ module NA
|
|
447
770
|
[optional, required, negated]
|
448
771
|
end
|
449
772
|
|
450
|
-
##
|
451
|
-
## Find a matching path using semi-fuzzy matching.
|
452
|
-
## Search tokens can include ! and + to negate or make
|
453
|
-
## required.
|
454
|
-
##
|
455
|
-
## @param search [Array] search tokens to match
|
456
|
-
## @param distance [Integer] allowed distance
|
457
|
-
## between characters
|
458
|
-
##
|
459
|
-
def match_working_dir(search, distance: 1)
|
460
|
-
file = database_path
|
461
|
-
notify('{r}No na database found', exit_code: 1) unless File.exist?(file)
|
462
|
-
|
463
|
-
dirs = file.read_file.split("\n")
|
464
|
-
|
465
|
-
optional = search.map { |t| t[:token] }
|
466
|
-
required = search.filter { |s| s[:required] }.map { |t| t[:token] }
|
467
|
-
|
468
|
-
NA.notify("{bw}Optional directory regex: {x}#{optional.map(&:dir_to_rx)}", debug: true)
|
469
|
-
NA.notify("{bw}Required directory regex: {x}#{required.map(&:dir_to_rx)}", debug: true)
|
470
|
-
|
471
|
-
dirs.delete_if { |d| !d.sub(/\.#{NA.extension}$/, '').dir_matches(any: optional, all: required) }
|
472
|
-
dirs.sort.uniq
|
473
|
-
end
|
474
|
-
|
475
773
|
##
|
476
774
|
## Save a todo file path to the database
|
477
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/
|