na 1.1.26 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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