na 1.1.26 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/na CHANGED
@@ -95,13 +95,16 @@ class App
95
95
  end
96
96
 
97
97
  if args.count.positive?
98
+ all_req = false
99
+
98
100
  tokens = []
99
101
  args.each do |arg|
100
102
  arg.split(/ *, */).each do |a|
101
- m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
103
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
102
104
  tokens.push({
103
105
  token: m['tok'],
104
- required: !m['req'].nil?
106
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
107
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
105
108
  })
106
109
  end
107
110
  end
@@ -110,12 +113,12 @@ class App
110
113
  NA.na_tag = options[:tag] unless options[:tag].nil?
111
114
  require_na = true
112
115
 
113
- tag = [{ tag: tag, value: nil }]
114
- files, actions = NA.parse_actions(depth: depth,
115
- query: tokens,
116
- tag: tag,
117
- project: options[:project],
118
- require_na: require_na)
116
+ tag = [{ tag: tag, value: nil }, { tag: 'done', value: nil, negate: true}]
117
+ files, actions, = NA.parse_actions(depth: depth,
118
+ query: tokens,
119
+ tag: tag,
120
+ project: options[:project],
121
+ require_na: require_na)
119
122
 
120
123
  NA.output_actions(actions, depth, files: files)
121
124
  end
@@ -146,7 +149,11 @@ class App
146
149
  c.desc 'Add action to specific project'
147
150
  c.arg_name 'PROJECT'
148
151
  c.default_value 'Inbox'
149
- c.flag %i[to]
152
+ c.flag %i[to project proj]
153
+
154
+ c.desc 'Add to a known todo file, partial matches allowed'
155
+ c.arg_name 'TODO_FILE'
156
+ c.flag %i[in todo]
150
157
 
151
158
  c.desc 'Use a tag other than the default next action tag'
152
159
  c.arg_name 'TAG'
@@ -183,7 +190,7 @@ class App
183
190
  action = "#{action.gsub(/@priority\(\d+\)/, '')} @priority(#{options[:priority]})"
184
191
  end
185
192
 
186
- note_rx = /^(.+)\((.*?)\)$/
193
+ note_rx = /^(.+) \((.*?)\)$/
187
194
  split_note = if action =~ note_rx
188
195
  n = Regexp.last_match(2)
189
196
  action.sub!(note_rx, '\1').strip!
@@ -229,6 +236,34 @@ class App
229
236
  Process.exit 1
230
237
  end
231
238
  end
239
+ elsif options[:todo]
240
+ todo = []
241
+ options[:todo].split(/ *, */).each do |a|
242
+ m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
243
+ todo.push({
244
+ token: m['tok'],
245
+ required: !m['req'].nil?
246
+ })
247
+ end
248
+ dirs = NA.match_working_dir(todo)
249
+ if dirs.count.positive?
250
+ target = dirs[0]
251
+ else
252
+ todo = "#{options[:todo].sub(/#{NA.extension}$/, '')}.#{NA.extension}"
253
+ target = File.expand_path(todo)
254
+ unless File.exist?(target)
255
+
256
+ res = NA.yn(NA::Color.template("{by}Specified file not found, create #{todo}"), default: true)
257
+ if res
258
+ basename = File.basename(target, ".#{NA.extension}")
259
+ NA.create_todo(target, basename)
260
+ else
261
+ NA.notify('{r}Cancelled{x}', exit_code: 1)
262
+
263
+ end
264
+ end
265
+
266
+ end
232
267
  else
233
268
  files = NA.find_files(depth: options[:depth])
234
269
  if files.count.zero?
@@ -243,12 +278,209 @@ class App
243
278
  end
244
279
  target = files.count > 1 ? NA.select_file(files) : files[0]
245
280
  unless files.count.positive? && File.exist?(target)
246
- puts NA::Color.template('{r}Cancelled{x}')
247
- Process.exit 1
281
+ NA.notify('{r}Cancelled{x}', exit_code: 1)
282
+
283
+ end
284
+ end
285
+
286
+ NA.add_action(target, options[:project], action, note)
287
+ end
288
+ end
289
+
290
+ desc 'Update an existing action'
291
+ long_desc 'Provides an easy way to complete, prioritize, and tag existing actions.
292
+
293
+ If multiple todo files are found in the current directory, a menu will
294
+ allow you to pick which file to act on.'
295
+ arg_name 'ACTION'
296
+ command %i[update] do |c|
297
+ c.example 'na update --remove na "An existing task"', desc: 'Find "An existing task" action and remove the @na tag from it'
298
+ c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
299
+ desc: 'Find "A bug..." action, add @waiting, add/update @priority(4), and prompt for an additional note'
300
+ c.example 'na update --archive My cool action', desc: 'Add @done to "My cool action" and immediately move to Archive'
301
+
302
+ c.desc 'Prompt for additional notes. Input will be appended to any existing note.'
303
+ c.switch %i[n note], negatable: false
304
+
305
+ c.desc 'Overwrite note instead of appending'
306
+ c.switch %i[o overwrite], negatable: false
307
+
308
+ c.desc 'Add/change a priority level 1-5'
309
+ c.arg_name 'PRIO'
310
+ c.flag %i[p priority], must_match: /[1-5]/, type: :integer, default_value: 0
311
+
312
+ c.desc 'Move action to specific project'
313
+ c.arg_name 'PROJECT'
314
+ c.flag %i[to project proj]
315
+
316
+ c.desc 'Use a known todo file, partial matches allowed'
317
+ c.arg_name 'TODO_FILE'
318
+ c.flag %i[in todo]
319
+
320
+ c.desc 'Add a tag to the action, @tag(values) allowed'
321
+ c.arg_name 'TAG'
322
+ c.flag %i[t tag], multiple: true
323
+
324
+ c.desc 'Remove a tag to the action'
325
+ c.arg_name 'TAG'
326
+ c.flag %i[r remove], multiple: true
327
+
328
+ c.desc 'Add a @done tag to action'
329
+ c.switch %i[f finish done], negatable: false
330
+
331
+ c.desc 'Add a @done tag to action and move to Archive'
332
+ c.switch %i[a archive], negatable: false
333
+
334
+ c.desc 'Delete an action'
335
+ c.switch %i[delete], negatable: false
336
+
337
+ c.desc 'Specify the file to search for the task'
338
+ c.arg_name 'PATH'
339
+ c.flag %i[file]
340
+
341
+ c.desc 'Search for files X directories deep'
342
+ c.arg_name 'DEPTH'
343
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
344
+
345
+ c.desc 'Match actions containing tag. Allows value comparisons'
346
+ c.arg_name 'TAG'
347
+ c.flag %i[tagged], multiple: true
348
+
349
+ c.desc 'Act on all matches immediately (no menu)'
350
+ c.switch %i[all], negatable: false
351
+
352
+ c.desc 'Interpret search pattern as regular expression'
353
+ c.switch %i[e regex], negatable: false
354
+
355
+ c.desc 'Match pattern exactly'
356
+ c.switch %i[x exact], negatable: false
357
+
358
+ c.action do |_global_options, options, args|
359
+ reader = TTY::Reader.new
360
+ action = if args.count.positive?
361
+ args.join(' ').strip
362
+ elsif TTY::Which.exist?('gum') && options[:tagged].empty?
363
+ options = [
364
+ %(--placeholder "Enter a task to search for"),
365
+ '--char-limit=500',
366
+ "--width=#{TTY::Screen.columns}"
367
+ ]
368
+ `gum input #{options.join(' ')}`.strip
369
+ elsif options[:tagged].empty?
370
+ puts NA::Color.template('{bm}Enter search string:{x}')
371
+ reader.read_line(NA::Color.template('{by}> {bw}')).strip
372
+ end
373
+
374
+ if action
375
+ tokens = nil
376
+ if options[:exact]
377
+ tokens = action
378
+ elsif options[:regex]
379
+ tokens = Regexp.new(action, Regexp::IGNORECASE)
380
+ else
381
+ tokens = []
382
+ all_req = action !~ /[+!\-]/ && !options[:or]
383
+
384
+ action.split(/ /).each do |arg|
385
+ m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
386
+ tokens.push({
387
+ token: m['tok'],
388
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
389
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
390
+ })
391
+ end
392
+ end
393
+ end
394
+
395
+ if (action.nil? || action.empty?) && options[:tagged].empty?
396
+ puts 'Empty input, cancelled'
397
+ Process.exit 1
398
+ end
399
+
400
+ all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
401
+ tags = []
402
+ options[:tagged].join(',').split(/ *, */).each do |arg|
403
+ m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
404
+
405
+ tags.push({
406
+ tag: m['tag'].wildcard_to_rx,
407
+ comp: m['op'],
408
+ value: m['val'],
409
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
410
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
411
+ })
412
+ end
413
+
414
+ priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
415
+ add_tags = options[:tag].map { |t| t.sub(/^@/, '').wildcard_to_rx }
416
+ remove_tags = options[:remove].map { |t| t.sub(/^@/, '').wildcard_to_rx }
417
+
418
+ line_note = if options[:note]
419
+ if TTY::Which.exist?('gum')
420
+ args = ['--placeholder "Enter a note, CTRL-d to save"']
421
+ args << '--char-limit 0'
422
+ args << '--width $(tput cols)'
423
+ `gum write #{args.join(' ')}`.strip.split("\n")
424
+ else
425
+ puts NA::Color.template('{bm}Enter a note, {bw}CTRL-d{bm} to end editing{bw}')
426
+ reader.read_multiline
427
+ end
428
+ end
429
+
430
+ note = line_note.nil? || line_note.empty? ? [] : line_note
431
+
432
+ if options[:file]
433
+ file = File.expand_path(options[:file])
434
+ NA.notify('{r}File not found', exit_code: 1) unless File.exist?(file)
435
+
436
+ targets = [file]
437
+ elsif options[:todo]
438
+ todo = []
439
+ options[:todo].split(/ *, */).each do |a|
440
+ m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
441
+ todo.push({
442
+ token: m['tok'],
443
+ required: !m['req'].nil?
444
+ })
445
+ end
446
+ dirs = NA.match_working_dir(todo)
447
+
448
+ if dirs.count == 1
449
+ targets = [dirs[0]]
450
+ elsif dirs.count.positive?
451
+ targets = NA.select_file(dirs, multiple: true)
452
+ NA.notify('{r}Cancelled', exit_code: 1) unless targets && targets.count.positive?
453
+ else
454
+ NA.notify('{r}Todo not found', exit_code: 1) unless targets && targets.count.positive?
455
+
248
456
  end
457
+ else
458
+ files = NA.find_files(depth: options[:depth])
459
+ NA.notify('{r}No todo file found', exit_code: 1) if files.count.zero?
460
+
461
+ targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
462
+ NA.notify('{r}Cancelled{x}', exit_code: 1) unless files.count.positive?
463
+
249
464
  end
250
465
 
251
- NA.add_action(target, options[:to], action, note)
466
+ options[:finish] = true if options[:archive]
467
+ options[:project] = 'Archive' if options[:archive]
468
+
469
+ NA.notify('{r}No search terms provided', exit_code: 1) if tokens.nil? && options[:tagged].empty?
470
+
471
+ targets.each do |target|
472
+ NA.update_action(target, tokens,
473
+ priority: priority,
474
+ add_tag: add_tags,
475
+ remove_tag: remove_tags,
476
+ finish: options[:finish],
477
+ project: options[:project],
478
+ delete: options[:delete],
479
+ note: note,
480
+ overwrite: options[:overwrite],
481
+ tagged: tags,
482
+ all: options[:all])
483
+ end
252
484
  end
253
485
  end
254
486
 
@@ -291,7 +523,7 @@ class App
291
523
  c.action do |global_options, options, args|
292
524
  if options[:save]
293
525
  title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
294
- NA.save_search(title, "find #{NA.command_line.map { |c| "\"#{c}\"" }.join(' ')}")
526
+ NA.save_search(title, "find #{NA.command_line.map { |cmd| "\"#{cmd}\"" }.join(' ')}")
295
527
  end
296
528
 
297
529
  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
@@ -324,24 +556,24 @@ class App
324
556
  options[:in].split(/ *, */).each do |a|
325
557
  m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
326
558
  todo.push({
327
- token: m['tok'],
328
- required: !m['req'].nil?
329
- })
559
+ token: m['tok'],
560
+ required: !m['req'].nil?
561
+ })
330
562
  end
331
563
  end
332
564
 
333
- files, actions = NA.parse_actions(depth: depth,
334
- query: todo,
335
- search: tokens,
336
- negate: options[:invert],
337
- regex: options[:regex],
338
- project: options[:project],
339
- require_na: false)
340
- if tokens.is_a?(Array)
341
- regexes = tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
342
- else
343
- regexes = [tokens]
344
- end
565
+ files, actions, = NA.parse_actions(depth: depth,
566
+ query: todo,
567
+ search: tokens,
568
+ negate: options[:invert],
569
+ regex: options[:regex],
570
+ project: options[:project],
571
+ require_na: false)
572
+ regexes = if tokens.is_a?(Array)
573
+ tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
574
+ else
575
+ [tokens]
576
+ end
345
577
 
346
578
  NA.output_actions(actions, depth, files: files, regexes: regexes)
347
579
  end
@@ -401,7 +633,6 @@ class App
401
633
 
402
634
  all_req = args.join(' ') !~ /[+!\-]/ && !options[:or]
403
635
  args.join(',').split(/ *, */).each do |arg|
404
- # TODO: <> comparisons do nothing right now
405
636
  m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
406
637
 
407
638
  tags.push({
@@ -413,6 +644,10 @@ class App
413
644
  })
414
645
  end
415
646
 
647
+ search_for_done = false
648
+ tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
649
+ tags.push({ tag: 'done', value: nil, negate: true}) unless search_for_done
650
+
416
651
  todo = nil
417
652
  if options[:in]
418
653
  todo = []
@@ -425,12 +660,12 @@ class App
425
660
  end
426
661
  end
427
662
 
428
- files, actions = NA.parse_actions(depth: depth,
429
- query: todo,
430
- tag: tags,
431
- negate: options[:invert],
432
- project: options[:project],
433
- require_na: false)
663
+ files, actions, = NA.parse_actions(depth: depth,
664
+ query: todo,
665
+ tag: tags,
666
+ negate: options[:invert],
667
+ project: options[:project],
668
+ require_na: false)
434
669
  regexes = tags.delete_if { |token| token[:negate] }.map { |token| token[:token] }
435
670
  NA.output_actions(actions, depth, files: files, regexes: regexes)
436
671
  end
@@ -484,13 +719,15 @@ class App
484
719
  c.arg_name 'EDITOR'
485
720
  c.flag %i[a app]
486
721
 
487
- c.action do |global_options, options, _args|
722
+ c.action do |global_options, options, args|
488
723
  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
489
724
  3
490
725
  else
491
726
  options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
492
727
  end
493
728
  files = NA.find_files(depth: depth)
729
+ files.delete_if { |f| f !~ /.*?(#{args.join('|')}).*?.#{NA.extension}/ } if args.count.positive?
730
+
494
731
  file = if files.count > 1
495
732
  NA.select_file(files)
496
733
  else
@@ -513,13 +750,16 @@ class App
513
750
  command %i[todos] do |c|
514
751
  c.action do |_global_options, _options, args|
515
752
  if args.count.positive?
516
- tokens = []
753
+ all_req = args.join(' ') !~ /[+!\-]/
754
+
755
+ tokens = [{ token: '*', required: all_req, negate: false }]
517
756
  args.each do |arg|
518
757
  arg.split(/ *, */).each do |a|
519
- m = a.match(/^(?<req>\+)?(?<tok>.*?)$/)
758
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
520
759
  tokens.push({
521
760
  token: m['tok'],
522
- required: !m['req'].nil?
761
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
762
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
523
763
  })
524
764
  end
525
765
  end
@@ -529,6 +769,39 @@ class App
529
769
  end
530
770
  end
531
771
 
772
+ desc 'Show list of projects for a file'
773
+ long_desc 'Arguments will be interpreted as a query for a known todo file,
774
+ fuzzy matched. Separate directories with /, :, or a space, e.g. `na projects code/marked`'
775
+ arg_name 'QUERY', optional: true
776
+ command %i[projects] do |c|
777
+ c.desc 'Search for files X directories deep'
778
+ c.arg_name 'DEPTH'
779
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
780
+
781
+ c.desc 'Output projects as paths instead of hierarchy'
782
+ c.switch %i[p paths], negatable: false
783
+
784
+ c.action do |_global_options, options, args|
785
+ if args.count.positive?
786
+ all_req = args.join(' ') !~ /[+!\-]/
787
+
788
+ tokens = [{ token: '*', required: all_req, negate: false }]
789
+ args.each do |arg|
790
+ arg.split(/ *, */).each do |a|
791
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
792
+ tokens.push({
793
+ token: m['tok'],
794
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
795
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
796
+ })
797
+ end
798
+ end
799
+ end
800
+
801
+ NA.list_projects(query: tokens, depth: options[:depth], paths: options[:paths])
802
+ end
803
+ end
804
+
532
805
  desc 'Show or install prompt hooks for the current shell'
533
806
  long_desc 'Installing the prompt hook allows you to automatically
534
807
  list next actions when you cd into a directory'
@@ -581,12 +854,30 @@ class App
581
854
  long_desc 'Run without argument to list saved searches'
582
855
  arg_name 'SEARCH_TITLE', optional: true
583
856
  command %i[saved] do |c|
584
- c.action do |_global_options, _options, args|
857
+ c.example 'na saved overdue', description: 'perform the search named "overdue"'
858
+ c.example 'na saved over', description: 'perform the search named "overdue", assuming no other searches match "over"'
859
+ c.example 'na saved', description: 'list available searches'
860
+
861
+ c.desc 'Open the saved search file in $EDITOR'
862
+ c.switch %i[e edit]
863
+
864
+ c.desc 'Delete the specified search definition'
865
+ c.switch %i[d delete]
866
+
867
+ c.action do |_global_options, options, args|
868
+ if options[:edit]
869
+ NA.edit_searches
870
+ end
871
+
585
872
  searches = NA.load_searches
586
873
  if args.empty?
587
874
  NA.notify("{bg}Saved searches stored in {bw}#{NA.database_path(file: 'saved_searches.yml')}")
588
875
  NA.notify(searches.map { |k, v| "{y}#{k}: {w}#{v}" }.join("\n"), exit_code: 0)
589
876
  else
877
+ if options[:delete]
878
+ NA.delete_search(args)
879
+ end
880
+
590
881
  keys = searches.keys.delete_if { |k| k !~ /#{args[0]}/ }
591
882
  NA.notify("{r}Search #{args[0]} not found", exit_code: 1) if keys.empty?
592
883
 
data/lib/na/action.rb CHANGED
@@ -2,20 +2,22 @@
2
2
 
3
3
  module NA
4
4
  class Action < Hash
5
- attr_reader :file, :project, :parent, :action, :tags
5
+ attr_reader :file, :project, :parent, :action, :tags, :line, :note
6
6
 
7
- def initialize(file, project, parent, action)
7
+ def initialize(file, project, parent, action, idx, note = [])
8
8
  super()
9
9
 
10
10
  @file = file
11
11
  @project = project
12
12
  @parent = parent
13
- @action = action
13
+ @action = action.gsub(/\{/, '\\{')
14
14
  @tags = scan_tags
15
+ @line = idx
16
+ @note = note
15
17
  end
16
18
 
17
19
  def to_s
18
- "(#{@file}) #{@project}:#{@parent.join('>')} | #{@action}"
20
+ "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}"
19
21
  end
20
22
 
21
23
  def inspect
@@ -41,14 +43,10 @@ module NA
41
43
  }
42
44
  template = default_template.merge(template)
43
45
 
44
- if @parent != ['Inbox']
45
- parents = @parent.map do |par|
46
- NA::Color.template("#{template[:parent]}#{par}")
47
- end.join(NA::Color.template(template[:parent_divider]))
48
- parents = "{dc}[{x}#{parents}{dc}]{x} "
49
- else
50
- parents = ''
51
- end
46
+ parents = @parent.map do |par|
47
+ NA::Color.template("#{template[:parent]}#{par}")
48
+ end.join(NA::Color.template(template[:parent_divider]))
49
+ parents = "{dc}[{x}#{parents}{dc}]{x} "
52
50
 
53
51
  project = NA::Color.template("#{template[:project]}#{@project}{x} ")
54
52
 
@@ -67,7 +65,7 @@ module NA
67
65
  NA::Color.template(template[:output].gsub(/%filename/, filename)
68
66
  .gsub(/%project/, project)
69
67
  .gsub(/%parents?/, parents)
70
- .gsub(/%action/, action.highlight_search(regexes)))
68
+ .gsub(/%action/, action.highlight_search(regexes))).gsub(/\\\{/, '{')
71
69
  end
72
70
 
73
71
  def tags_match?(any: [], all: [], none: [])
data/lib/na/colors.rb CHANGED
@@ -226,8 +226,9 @@ module NA
226
226
  ##
227
227
  def template(input)
228
228
  input = input.join(' ') if input.is_a? Array
229
+
229
230
  fmt = input.gsub(/%/, '%%')
230
- fmt = fmt.gsub(/(?<!\\u)\{(\w+)\}/i) do
231
+ fmt = fmt.gsub(/(?<!\\)\{(\w+)\}/i) do
231
232
  Regexp.last_match(1).split('').map { |c| "%<#{c}>s" }.join('')
232
233
  end
233
234