na 1.1.26 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 If `gum` or `fzf` are available, they'll be used (in that order)
110
+ ## @note If `gum` or `fzf` are available, they'll
111
+ ## be used (in that order)
110
112
  ##
111
- ## @param files [Array] The 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?('gum')
115
- args = [
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 to search for files
202
- ## @param query [Hash] The project query
203
- ## @param tag [Hash] Tags to search for
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 regular expression
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 query.nil?
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
- content.split("\n").each do |line|
263
- if line =~ /([ \t]*)([^\-]+.*?): *(@\S+ *)*$/
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]*- / && line !~ / @done/
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
- [files, actions]
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 The 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: 2)
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
- matches_any(any.map(&:dir_to_rx)) && matches_all(all.map(&:dir_to_rx))
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
@@ -1,3 +1,3 @@
1
1
  module Na
2
- VERSION = '1.1.26'
2
+ VERSION = '1.2.0'
3
3
  end
data/lib/na.rb CHANGED
@@ -11,6 +11,7 @@ require 'tty-which'
11
11
  require 'na/hash'
12
12
  require 'na/colors'
13
13
  require 'na/string'
14
+ require 'na/project'
14
15
  require 'na/action'
15
16
  require 'na/next_action'
16
17
  require 'na/prompt'
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.25<!--END VER-->.
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 spaces or 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.
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/