na 1.2.27 → 1.2.29

Sign up to get free protection for your applications and to get access to all the features.
data/bin/na CHANGED
@@ -7,7 +7,8 @@ require 'na'
7
7
  require 'fcntl'
8
8
 
9
9
  # Main application
10
- include GLI::App
10
+ class App
11
+ extend GLI::App
11
12
 
12
13
  program_desc 'Add and list next actions for the current project'
13
14
 
@@ -72,16 +73,1173 @@ include GLI::App
72
73
  desc 'Display verbose output'
73
74
  switch %i[debug], default_value: false
74
75
 
75
- # def add_commands(commands)
76
- # commands = [commands] unless commands.is_a?(Array)
77
- # commands.each { |cmd| require_relative "commands/#{cmd}" }
78
- # end
76
+ desc 'Show next actions'
77
+ long_desc 'Next actions are actions which contain the next action tag (default @na),
78
+ do not contain @done, and are not in the Archive project.
79
79
 
80
- # ## Add/modify commands
81
- # add_commands(%w[next add complete archive update])
82
- # add_commands(%w[find tagged])
83
- # add_commands(%w[init edit todos projects prompt changes saved])
84
- commands_from File.expand_path('bin/commands')
80
+ Arguments will target a todo file from history, whether it\'s in the current
81
+ directory or not. Todo file queries can include path components separated by /
82
+ or :, and may use wildcards (`*` to match any text, `?` to match a single character). Multiple queries allowed (separate arguments or separated by comma).'
83
+ arg_name 'QUERY', optional: true
84
+ command %i[next show] do |c|
85
+ c.example 'na next', desc: 'display the next actions from any todo files in the current directory'
86
+ c.example 'na next -d 3', desc: 'display the next actions from the current directory, traversing 3 levels deep'
87
+ c.example 'na next marked', desc: 'display next actions for a project you visited in the past'
88
+
89
+ c.desc 'Recurse to depth'
90
+ c.arg_name 'DEPTH'
91
+ c.flag %i[d depth], type: :integer, must_match: /^[1-9]$/
92
+
93
+ c.desc 'Display matches from a known todo file'
94
+ c.arg_name 'TODO_FILE'
95
+ c.flag %i[in todo], multiple: true
96
+
97
+ c.desc 'Alternate tag to search for'
98
+ c.arg_name 'TAG'
99
+ c.flag %i[t tag]
100
+
101
+ c.desc 'Show actions from a specific project'
102
+ c.arg_name 'PROJECT[/SUBPROJECT]'
103
+ c.flag %i[proj project]
104
+
105
+ c.desc 'Match actions containing tag. Allows value comparisons'
106
+ c.arg_name 'TAG'
107
+ c.flag %i[tagged], multiple: true
108
+
109
+ c.desc 'Filter results using search terms'
110
+ c.arg_name 'QUERY'
111
+ c.flag %i[search], multiple: true
112
+
113
+ c.desc 'Search query is regular expression'
114
+ c.switch %i[regex], negatable: false
115
+
116
+ c.desc 'Search query is exact text match (not tokens)'
117
+ c.switch %i[exact], negatable: false
118
+
119
+ c.desc 'Include notes in output'
120
+ c.switch %i[notes], negatable: true, default_value: false
121
+
122
+ c.desc 'Include @done actions'
123
+ c.switch %i[done]
124
+
125
+ c.desc 'Output actions nested by file'
126
+ c.switch %[nest], negatable: false
127
+
128
+ c.desc 'Output actions nested by file and project'
129
+ c.switch %[omnifocus], negatable: false
130
+
131
+ c.action do |global_options, options, args|
132
+ if global_options[:add]
133
+ cmd = ['add']
134
+ cmd.push('--note') if global_options[:note]
135
+ cmd.concat(['--priority', global_options[:priority]]) if global_options[:priority]
136
+ cmd.push(NA.command_line) if NA.command_line.count > 1
137
+ cmd.unshift(*NA.globals)
138
+ exit run(cmd)
139
+ end
140
+
141
+ options[:nest] = true if options[:omnifocus]
142
+
143
+ depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
144
+ 3
145
+ else
146
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
147
+ end
148
+
149
+ all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
150
+ tags = []
151
+ options[:tagged].join(',').split(/ *, */).each do |arg|
152
+ m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
153
+
154
+ tags.push({
155
+ tag: m['tag'].wildcard_to_rx,
156
+ comp: m['op'],
157
+ value: m['val'],
158
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
159
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
160
+ })
161
+ end
162
+
163
+ args.concat(options[:in])
164
+ if args.count.positive?
165
+ all_req = args.join(' ') !~ /[+!\-]/
166
+
167
+ tokens = []
168
+ args.each do |arg|
169
+ arg.split(/ *, */).each do |a|
170
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
171
+ tokens.push({
172
+ token: m['tok'],
173
+ required: !m['req'].nil? && m['req'] == '+',
174
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
175
+ })
176
+ end
177
+ end
178
+ end
179
+
180
+ search = nil
181
+ if options[:search]
182
+ if options[:exact]
183
+ search = options[:search].join(' ')
184
+ elsif options[:regex]
185
+ search = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
186
+ else
187
+ search = []
188
+ all_req = options[:search].join(' ') !~ /[+!\-]/ && !options[:or]
189
+
190
+ options[:search].join(' ').split(/ /).each do |arg|
191
+ m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
192
+ search.push({
193
+ token: m['tok'],
194
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
195
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
196
+ })
197
+ end
198
+ end
199
+ end
200
+
201
+ NA.na_tag = options[:tag] unless options[:tag].nil?
202
+ require_na = true
203
+
204
+ tag = [{ tag: NA.na_tag, value: nil }, { tag: 'done', value: nil, negate: true }]
205
+ tag.concat(tags)
206
+ files, actions, = NA.parse_actions(depth: depth,
207
+ done: options[:done],
208
+ query: tokens,
209
+ tag: tag,
210
+ search: search,
211
+ project: options[:project],
212
+ require_na: require_na)
213
+
214
+ NA.output_actions(actions, depth, files: files, notes: options[:notes], nest: options[:nest], nest_projects: options[:omnifocus])
215
+ end
216
+ end
217
+
218
+ desc 'Add a new next action'
219
+ long_desc 'Provides an easy way to store todos while you work. Add quick
220
+ reminders and (if you set up Prompt Hooks) they\'ll automatically display
221
+ next time you enter the directory.
222
+
223
+ If multiple todo files are found in the current directory, a menu will
224
+ allow you to pick to which file the action gets added.'
225
+ arg_name 'ACTION'
226
+ command :add do |c|
227
+ c.example 'na add "A cool feature I thought of @idea"', desc: 'Add a new action to the Inbox, including a tag'
228
+ c.example 'na add "A bug I need to fix" -p 4 -n',
229
+ desc: 'Add a new action to the Inbox, set its @priority to 4, and prompt for an additional note.'
230
+ c.example 'na add "An action item (with a note)"',
231
+ desc: 'A parenthetical at the end of an action is interpreted as a note'
232
+
233
+ c.desc 'Prompt for additional notes. STDIN input (piped) will be treated as a note if present.'
234
+ c.switch %i[n note], negatable: false
235
+
236
+ c.desc 'Add a priority level 1-5'
237
+ c.arg_name 'PRIO'
238
+ c.flag %i[p priority], must_match: /[1-5]/, type: :integer, default_value: 0
239
+
240
+ c.desc 'Add action to specific project'
241
+ c.arg_name 'PROJECT'
242
+ c.default_value 'Inbox'
243
+ c.flag %i[to project proj]
244
+
245
+ c.desc 'Add task at [s]tart or [e]nd of target project'
246
+ c.arg_name 'POSITION'
247
+ c.flag %i[at], must_match: /^[sbea].*?$/i
248
+
249
+ c.desc 'Add to a known todo file, partial matches allowed'
250
+ c.arg_name 'TODO_FILE'
251
+ c.flag %i[in todo]
252
+
253
+ c.desc 'Use a tag other than the default next action tag'
254
+ c.arg_name 'TAG'
255
+ c.flag %i[t tag]
256
+
257
+ c.desc 'Don\'t add next action tag to new entry'
258
+ c.switch %i[x], negatable: false
259
+
260
+ c.desc 'Specify the file to which the task should be added'
261
+ c.arg_name 'PATH'
262
+ c.flag %i[f file]
263
+
264
+ c.desc 'Mark task as @done with date'
265
+ c.switch %i[finish done], negatable: false
266
+
267
+ c.desc 'Search for files X directories deep'
268
+ c.arg_name 'DEPTH'
269
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
270
+
271
+ c.action do |global_options, options, args|
272
+ reader = TTY::Reader.new
273
+ append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/
274
+
275
+ if NA.global_file
276
+ target = File.expand_path(NA.global_file)
277
+ unless File.exist?(target)
278
+ res = NA.yn(NA::Color.template('{by}Specified file not found, create it'), default: true)
279
+ if res
280
+ basename = File.basename(target, ".#{NA.extension}")
281
+ NA.create_todo(target, basename, template: global_options[:template])
282
+ else
283
+ puts NA::Color.template('{r}Cancelled{x}')
284
+ Process.exit 1
285
+ end
286
+ end
287
+ elsif options[:file]
288
+ target = File.expand_path(options[:file])
289
+ unless File.exist?(target)
290
+ res = NA.yn(NA::Color.template('{by}Specified file not found, create it'), default: true)
291
+ if res
292
+ basename = File.basename(target, ".#{NA.extension}")
293
+ NA.create_todo(target, basename, template: global_options[:template])
294
+ else
295
+ puts NA::Color.template('{r}Cancelled{x}')
296
+ Process.exit 1
297
+ end
298
+ end
299
+ elsif options[:todo]
300
+ todo = []
301
+ all_req = options[:todo] !~ /[+!\-]/
302
+ options[:todo].split(/ *, */).each do |a|
303
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
304
+ todo.push({
305
+ token: m['tok'],
306
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
307
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
308
+ })
309
+ end
310
+ dirs = NA.match_working_dir(todo)
311
+ if dirs.count.positive?
312
+ target = dirs[0]
313
+ else
314
+ todo = "#{options[:todo].sub(/#{NA.extension}$/, '')}.#{NA.extension}"
315
+ target = File.expand_path(todo)
316
+ unless File.exist?(target)
317
+
318
+ res = NA.yn(NA::Color.template("{by}Specified file not found, create #{todo}"), default: true)
319
+ NA.notify('{r}Cancelled{x}', exit_code: 1) unless res
320
+
321
+ basename = File.basename(target, ".#{NA.extension}")
322
+ NA.create_todo(target, basename, template: global_options[:template])
323
+ end
324
+
325
+ end
326
+ else
327
+ files = NA.find_files(depth: options[:depth])
328
+ if files.count.zero?
329
+ res = NA.yn(NA::Color.template('{by}No todo file found, create one'), default: true)
330
+ if res
331
+ basename = File.expand_path('.').split('/').last
332
+ target = "#{basename}.#{NA.extension}"
333
+ NA.create_todo(target, basename, template: global_options[:template])
334
+ files = NA.find_files(depth: 1)
335
+ end
336
+ end
337
+ target = files.count > 1 ? NA.select_file(files) : files[0]
338
+ NA.notify('{r}Cancelled{x}', exit_code: 1) unless files.count.positive? && File.exist?(target)
339
+
340
+ end
341
+
342
+ action = if args.count.positive?
343
+ args.join(' ').strip
344
+ elsif $stdin.isatty && TTY::Which.exist?('gum')
345
+ `gum input --placeholder "Enter a task" --char-limit=500 --width=#{TTY::Screen.columns}`.strip
346
+ elsif $stdin.isatty
347
+ puts NA::Color.template('{bm}Enter task:{x}')
348
+ reader.read_line(NA::Color.template('{by}> {bw}')).strip
349
+ end
350
+
351
+ if action.nil? || action.empty?
352
+ puts 'Empty input, cancelled'
353
+ Process.exit 1
354
+ end
355
+
356
+ if options[:priority]&.to_i&.positive?
357
+ action = "#{action.gsub(/@priority\(\d+\)/, '')} @priority(#{options[:priority]})"
358
+ end
359
+
360
+ note_rx = /^(.+) \((.*?)\)$/
361
+ split_note = if action =~ note_rx
362
+ n = Regexp.last_match(2)
363
+ action.sub!(note_rx, '\1').strip!
364
+ n
365
+ end
366
+
367
+ na_tag = NA.na_tag
368
+ if options[:x]
369
+ na_tag = ''
370
+ else
371
+ na_tag = options[:tag] unless options[:tag].nil?
372
+ na_tag = " @#{na_tag}"
373
+ end
374
+
375
+ action = "#{action.gsub(/#{na_tag}\b/, '')}#{na_tag}"
376
+
377
+ stdin_note = NA.stdin ? NA.stdin.split("\n") : []
378
+
379
+ line_note = if options[:note] && $stdin.isatty
380
+ puts stdin_note unless stdin_note.nil?
381
+ if TTY::Which.exist?('gum')
382
+ args = ['--placeholder "Enter additional note, CTRL-d to save"']
383
+ args << '--char-limit 0'
384
+ args << '--width $(tput cols)'
385
+ `gum write #{args.join(' ')}`.strip.split("\n")
386
+ else
387
+ puts NA::Color.template('{bm}Enter a note, {bw}CTRL-d{bm} to end editing{bw}')
388
+ reader.read_multiline
389
+ end
390
+ end
391
+
392
+ note = stdin_note.empty? ? [] : stdin_note
393
+ note.concat(split_note) unless split_note.nil?
394
+ note.concat(line_note) unless line_note.nil?
395
+
396
+ NA.add_action(target, options[:project], action, note, finish: options[:finish], append: append)
397
+ end
398
+ end
399
+
400
+ desc 'Find and mark an action as @done'
401
+ arg_name 'ACTION'
402
+ command %i[complete finish] do |c|
403
+ c.example 'na complete "An existing task"',
404
+ desc: 'Find "An existing task" and mark @done'
405
+ c.example 'na finish "An existing task"',
406
+ desc: 'Alias for complete'
407
+
408
+ c.desc 'Prompt for additional notes. Input will be appended to any existing note.
409
+ If STDIN input (piped) is detected, it will be used as a note.'
410
+ c.switch %i[n note], negatable: false
411
+
412
+ c.desc 'Overwrite note instead of appending'
413
+ c.switch %i[o overwrite], negatable: false
414
+
415
+ c.desc 'Add a @done tag to action and move to Archive'
416
+ c.switch %i[a archive], negatable: false
417
+
418
+ c.desc 'Specify the file to search for the task'
419
+ c.arg_name 'PATH'
420
+ c.flag %i[file]
421
+
422
+ c.desc 'Search for files X directories deep'
423
+ c.arg_name 'DEPTH'
424
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
425
+
426
+ c.desc 'Match actions containing tag. Allows value comparisons'
427
+ c.arg_name 'TAG'
428
+ c.flag %i[tagged], multiple: true
429
+
430
+ c.desc 'Act on all matches immediately (no menu)'
431
+ c.switch %i[all], negatable: false
432
+
433
+ c.desc 'Interpret search pattern as regular expression'
434
+ c.switch %i[e regex], negatable: false
435
+
436
+ c.desc 'Match pattern exactly'
437
+ c.switch %i[x exact], negatable: false
438
+
439
+ c.action do |global, options, args|
440
+ options[:finish] = true
441
+ options[:f] = true
442
+ options[:project] = 'Archive' if options[:archive]
443
+
444
+ cmd = commands[:update]
445
+ action = cmd.send(:get_action, nil)
446
+ action.call(global, options, args)
447
+ end
448
+ end
449
+
450
+ desc 'Mark an action as @done and archive'
451
+ arg_name 'ACTION'
452
+ command %i[archive] do |c|
453
+ c.example 'na archive "An existing task"',
454
+ desc: 'Find "An existing task", mark @done if needed, and move to archive'
455
+
456
+ c.desc 'Prompt for additional notes. Input will be appended to any existing note.
457
+ If STDIN input (piped) is detected, it will be used as a note.'
458
+ c.switch %i[n note], negatable: false
459
+
460
+ c.desc 'Overwrite note instead of appending'
461
+ c.switch %i[o overwrite], negatable: false
462
+
463
+ c.desc 'Specify the file to search for the task'
464
+ c.arg_name 'PATH'
465
+ c.flag %i[file]
466
+
467
+ c.desc 'Search for files X directories deep'
468
+ c.arg_name 'DEPTH'
469
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
470
+
471
+ c.desc 'Archive all done tasks'
472
+ c.switch %i[done], negatable: false
473
+
474
+ c.desc 'Match actions containing tag. Allows value comparisons'
475
+ c.arg_name 'TAG'
476
+ c.flag %i[tagged], multiple: true
477
+
478
+ c.desc 'Act on all matches immediately (no menu)'
479
+ c.switch %i[all], negatable: false
480
+
481
+ c.desc 'Interpret search pattern as regular expression'
482
+ c.switch %i[e regex], negatable: false
483
+
484
+ c.desc 'Match pattern exactly'
485
+ c.switch %i[x exact], negatable: false
486
+
487
+ c.action do |global, options, args|
488
+ if options[:done]
489
+ options[:tagged] = ['done']
490
+ options[:all] = true
491
+ end
492
+
493
+ options[:done] = true
494
+ options[:finish] = true
495
+ options[:project] = 'Archive'
496
+ options[:archive] = true
497
+ options[:a] = true
498
+ cmd = commands[:update]
499
+ action = cmd.send(:get_action, nil)
500
+ action.call(global, options, args)
501
+ end
502
+ end
503
+
504
+ desc 'Update an existing action'
505
+ long_desc 'Provides an easy way to complete, prioritize, and tag existing actions.
506
+
507
+ If multiple todo files are found in the current directory, a menu will
508
+ allow you to pick which file to act on.'
509
+ arg_name 'ACTION'
510
+ command %i[update] do |c|
511
+ c.example 'na update --remove na "An existing task"',
512
+ desc: 'Find "An existing task" action and remove the @na tag from it'
513
+ c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
514
+ desc: 'Find "A bug..." action, add @waiting, add/update @priority(4), and prompt for an additional note'
515
+ c.example 'na update --archive My cool action',
516
+ desc: 'Add @done to "My cool action" and immediately move to Archive'
517
+
518
+ c.desc 'Prompt for additional notes. Input will be appended to any existing note.
519
+ If STDIN input (piped) is detected, it will be used as a note.'
520
+ c.switch %i[n note], negatable: false
521
+
522
+ c.desc 'Overwrite note instead of appending'
523
+ c.switch %i[o overwrite], negatable: false
524
+
525
+ c.desc 'Add/change a priority level 1-5'
526
+ c.arg_name 'PRIO'
527
+ c.flag %i[p priority], must_match: /[1-5]/, type: :integer, default_value: 0
528
+
529
+ c.desc 'When moving task, add at [s]tart or [e]nd of target project'
530
+ c.arg_name 'POSITION'
531
+ c.flag %i[at], must_match: /^[sbea].*?$/i
532
+
533
+ c.desc 'Move action to specific project'
534
+ c.arg_name 'PROJECT'
535
+ c.flag %i[to project proj]
536
+
537
+ c.desc 'Use a known todo file, partial matches allowed'
538
+ c.arg_name 'TODO_FILE'
539
+ c.flag %i[in todo]
540
+
541
+ c.desc 'Include @done actions'
542
+ c.switch %i[done]
543
+
544
+ c.desc 'Add a tag to the action, @tag(values) allowed'
545
+ c.arg_name 'TAG'
546
+ c.flag %i[t tag], multiple: true
547
+
548
+ c.desc 'Remove a tag to the action'
549
+ c.arg_name 'TAG'
550
+ c.flag %i[r remove], multiple: true
551
+
552
+ c.desc 'Add a @done tag to action'
553
+ c.switch %i[f finish], negatable: false
554
+
555
+ c.desc 'Add a @done tag to action and move to Archive'
556
+ c.switch %i[a archive], negatable: false
557
+
558
+ c.desc 'Delete an action'
559
+ c.switch %i[delete], negatable: false
560
+
561
+ c.desc 'Specify the file to search for the task'
562
+ c.arg_name 'PATH'
563
+ c.flag %i[file]
564
+
565
+ c.desc 'Search for files X directories deep'
566
+ c.arg_name 'DEPTH'
567
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
568
+
569
+ c.desc 'Match actions containing tag. Allows value comparisons'
570
+ c.arg_name 'TAG'
571
+ c.flag %i[tagged], multiple: true
572
+
573
+ c.desc 'Act on all matches immediately (no menu)'
574
+ c.switch %i[all], negatable: false
575
+
576
+ c.desc 'Interpret search pattern as regular expression'
577
+ c.switch %i[e regex], negatable: false
578
+
579
+ c.desc 'Match pattern exactly'
580
+ c.switch %i[x exact], negatable: false
581
+
582
+ c.action do |global_options, options, args|
583
+ reader = TTY::Reader.new
584
+ append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
585
+
586
+ action = if args.count.positive?
587
+ args.join(' ').strip
588
+ elsif $stdin.isatty && TTY::Which.exist?('gum') && options[:tagged].empty?
589
+ options = [
590
+ %(--placeholder "Enter a task to search for"),
591
+ '--char-limit=500',
592
+ "--width=#{TTY::Screen.columns}"
593
+ ]
594
+ `gum input #{options.join(' ')}`.strip
595
+ elsif $stdin.isatty && options[:tagged].empty?
596
+ puts NA::Color.template('{bm}Enter search string:{x}')
597
+ reader.read_line(NA::Color.template('{by}> {bw}')).strip
598
+ end
599
+
600
+ if action
601
+ tokens = nil
602
+ if options[:exact]
603
+ tokens = action
604
+ elsif options[:regex]
605
+ tokens = Regexp.new(action, Regexp::IGNORECASE)
606
+ else
607
+ tokens = []
608
+ all_req = action !~ /[+!\-]/ && !options[:or]
609
+
610
+ action.split(/ /).each do |arg|
611
+ m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
612
+ tokens.push({
613
+ token: m['tok'],
614
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
615
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
616
+ })
617
+ end
618
+ end
619
+ end
620
+
621
+ if (action.nil? || action.empty?) && options[:tagged].empty?
622
+ puts 'Empty input, cancelled'
623
+ Process.exit 1
624
+ end
625
+
626
+ all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
627
+ tags = []
628
+ options[:tagged].join(',').split(/ *, */).each do |arg|
629
+ m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
630
+
631
+ tags.push({
632
+ tag: m['tag'].wildcard_to_rx,
633
+ comp: m['op'],
634
+ value: m['val'],
635
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
636
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
637
+ })
638
+ end
639
+
640
+ priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
641
+ add_tags = options[:tag] ? options[:tag].map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
642
+ remove_tags = options[:remove] ? options[:remove].map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
643
+
644
+ stdin_note = NA.stdin ? NA.stdin.split("\n") : []
645
+
646
+ line_note = if options[:note] && $stdin.isatty
647
+ puts stdin_note unless stdin_note.nil?
648
+ if TTY::Which.exist?('gum')
649
+ args = ['--placeholder "Enter a note, CTRL-d to save"']
650
+ args << '--char-limit 0'
651
+ args << '--width $(tput cols)'
652
+ `gum write #{args.join(' ')}`.strip.split("\n")
653
+ else
654
+ puts NA::Color.template('{bm}Enter a note, {bw}CTRL-d{bm} to end editing{bw}')
655
+ reader.read_multiline
656
+ end
657
+ end
658
+
659
+ note = stdin_note.empty? ? [] : stdin_note
660
+ note.concat(line_note) unless line_note.nil? || line_note.empty?
661
+
662
+ target_proj = if options[:project]
663
+ options[:project]
664
+ elsif NA.cwd_is == :project
665
+ NA.cwd
666
+ else
667
+ nil
668
+ end
669
+
670
+ if options[:file]
671
+ file = File.expand_path(options[:file])
672
+ NA.notify('{r}File not found', exit_code: 1) unless File.exist?(file)
673
+
674
+ targets = [file]
675
+ elsif options[:todo]
676
+ todo = []
677
+ options[:todo].split(/ *, */).each do |a|
678
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
679
+ todo.push({
680
+ token: m['tok'],
681
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
682
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
683
+ })
684
+ end
685
+ dirs = NA.match_working_dir(todo)
686
+
687
+ if dirs.count == 1
688
+ targets = [dirs[0]]
689
+ elsif dirs.count.positive?
690
+ targets = NA.select_file(dirs, multiple: true)
691
+ NA.notify('{r}Cancelled', exit_code: 1) unless targets && targets.count.positive?
692
+ else
693
+ NA.notify('{r}Todo not found', exit_code: 1) unless targets && targets.count.positive?
694
+
695
+ end
696
+ else
697
+ files = NA.find_files(depth: options[:depth])
698
+ NA.notify('{r}No todo file found', exit_code: 1) if files.count.zero?
699
+
700
+ targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
701
+ NA.notify('{r}Cancelled{x}', exit_code: 1) unless files.count.positive?
702
+
703
+ end
704
+
705
+ if options[:archive]
706
+ options[:finish] = true
707
+ options[:project] = 'Archive'
708
+ end
709
+
710
+ NA.notify('{r}No search terms provided', exit_code: 1) if tokens.nil? && options[:tagged].empty?
711
+
712
+ targets.each do |target|
713
+ NA.update_action(target, tokens,
714
+ priority: priority,
715
+ add_tag: add_tags,
716
+ remove_tag: remove_tags,
717
+ finish: options[:finish],
718
+ project: target_proj,
719
+ delete: options[:delete],
720
+ note: note,
721
+ overwrite: options[:overwrite],
722
+ tagged: tags,
723
+ all: options[:all],
724
+ done: options[:done],
725
+ append: append)
726
+ end
727
+ end
728
+ end
729
+
730
+ desc 'Find actions matching a search pattern'
731
+ long_desc 'Search tokens are separated by spaces. Actions matching all tokens in the pattern will be shown
732
+ (partial matches allowed). Add a + before a token to make it required, e.g. `na find +feature +maybe`,
733
+ add a - or ! to ignore matches containing that token.'
734
+ arg_name 'PATTERN'
735
+ command %i[find grep] do |c|
736
+ c.example 'na find feature idea swift', desc: 'Find all actions containing feature, idea, and swift'
737
+ c.example 'na find feature idea -swift', desc: 'Find all actions containing feature and idea but NOT swift'
738
+ c.example 'na find -x feature idea', desc: 'Find all actions containing the exact text "feature idea"'
739
+
740
+ c.desc 'Interpret search pattern as regular expression'
741
+ c.switch %i[e regex], negatable: false
742
+
743
+ c.desc 'Match pattern exactly'
744
+ c.switch %i[x exact], negatable: false
745
+
746
+ c.desc 'Recurse to depth'
747
+ c.arg_name 'DEPTH'
748
+ c.flag %i[d depth], type: :integer, must_match: /^\d+$/
749
+
750
+ c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
751
+ c.arg_name 'TODO_PATH'
752
+ c.flag %i[in]
753
+
754
+ c.desc 'Include notes in output'
755
+ c.switch %i[notes], negatable: true, default_value: false
756
+
757
+ c.desc 'Combine search tokens with OR, displaying actions matching ANY of the terms'
758
+ c.switch %i[o or], negatable: false
759
+
760
+ c.desc 'Show actions from a specific project'
761
+ c.arg_name 'PROJECT[/SUBPROJECT]'
762
+ c.flag %i[proj project]
763
+
764
+ c.desc 'Match actions containing tag. Allows value comparisons'
765
+ c.arg_name 'TAG'
766
+ c.flag %i[tagged], multiple: true
767
+
768
+ c.desc 'Include @done actions'
769
+ c.switch %i[done]
770
+
771
+ c.desc 'Show actions not matching search pattern'
772
+ c.switch %i[v invert], negatable: false
773
+
774
+ c.desc 'Save this search for future use'
775
+ c.arg_name 'TITLE'
776
+ c.flag %i[save]
777
+
778
+ c.desc 'Output actions nested by file'
779
+ c.switch %[nest], negatable: false
780
+
781
+ c.desc 'Output actions nested by file and project'
782
+ c.switch %[omnifocus], negatable: false
783
+
784
+ c.action do |global_options, options, args|
785
+ options[:nest] = true if options[:omnifocus]
786
+
787
+ if options[:save]
788
+ title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
789
+ NA.save_search(title, "#{NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split(' ').map { |t| %("#{t}") }.join(' ')}")
790
+ end
791
+
792
+
793
+ depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
794
+ 3
795
+ else
796
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
797
+ end
798
+
799
+ all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
800
+ tags = []
801
+ options[:tagged].join(',').split(/ *, */).each do |arg|
802
+ m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
803
+
804
+ tags.push({
805
+ tag: m['tag'].wildcard_to_rx,
806
+ comp: m['op'],
807
+ value: m['val'],
808
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
809
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
810
+ })
811
+ end
812
+
813
+ tokens = nil
814
+ if options[:exact]
815
+ tokens = args.join(' ')
816
+ elsif options[:regex]
817
+ tokens = Regexp.new(args.join(' '), Regexp::IGNORECASE)
818
+ else
819
+ tokens = []
820
+ all_req = args.join(' ') !~ /[+!\-]/ && !options[:or]
821
+
822
+ args.join(' ').split(/ /).each do |arg|
823
+ m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
824
+ tokens.push({
825
+ token: m['tok'],
826
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
827
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
828
+ })
829
+ end
830
+ end
831
+
832
+ todo = nil
833
+ if options[:in]
834
+ todo = []
835
+ options[:in].split(/ *, */).each do |a|
836
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
837
+ todo.push({
838
+ token: m['tok'],
839
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
840
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
841
+ })
842
+ end
843
+ end
844
+
845
+ files, actions, = NA.parse_actions(depth: depth,
846
+ done: options[:done],
847
+ query: todo,
848
+ search: tokens,
849
+ tag: tags,
850
+ negate: options[:invert],
851
+ regex: options[:regex],
852
+ project: options[:project],
853
+ require_na: false)
854
+ regexes = if tokens.is_a?(Array)
855
+ tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
856
+ else
857
+ [tokens]
858
+ end
859
+
860
+ NA.output_actions(actions, depth, files: files, regexes: regexes, notes: options[:notes], nest: options[:nest], nest_projects: options[:omnifocus])
861
+ end
862
+ end
863
+
864
+ desc 'Find actions matching a tag'
865
+ long_desc 'Finds actions with tags matching the arguments. An action is shown if it
866
+ contains all of the tags listed. Add a + before a tag to make it required
867
+ and others optional. You can specify values using TAG=VALUE pairs.
868
+ Use <, >, and = for numeric comparisons, and *=, ^=, and $= for text comparisons.
869
+ Date comparisons use natural language (`na tagged "due<=today"`) and
870
+ are detected automatically.'
871
+ arg_name 'TAG[=VALUE]'
872
+ command %i[tagged] do |c|
873
+ c.example 'na tagged maybe', desc: 'Show all actions tagged @maybe'
874
+ c.example 'na tagged -d 3 "feature, idea"', desc: 'Show all actions tagged @feature AND @idea, recurse 3 levels'
875
+ c.example 'na tagged --or "feature, idea"', desc: 'Show all actions tagged @feature OR @idea'
876
+ c.example 'na tagged "priority>=4"', desc: 'Show actions with @priority(4) or @priority(5)'
877
+ c.example 'na tagged "due<in 2 days"', desc: 'Show actions with a due date coming up in the next 2 days'
878
+
879
+ c.desc 'Recurse to depth'
880
+ c.arg_name 'DEPTH'
881
+ c.default_value 1
882
+ c.flag %i[d depth], type: :integer, must_match: /^\d+$/
883
+
884
+ c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
885
+ c.arg_name 'TODO_PATH'
886
+ c.flag %i[in]
887
+
888
+ c.desc 'Include notes in output'
889
+ c.switch %i[notes], negatable: true, default_value: false
890
+
891
+ c.desc 'Combine tags with OR, displaying actions matching ANY of the tags'
892
+ c.switch %i[o or], negatable: false
893
+
894
+ c.desc 'Show actions from a specific project'
895
+ c.arg_name 'PROJECT[/SUBPROJECT]'
896
+ c.flag %i[proj project]
897
+
898
+ c.desc 'Filter results using search terms'
899
+ c.arg_name 'QUERY'
900
+ c.flag %i[search], multiple: true
901
+
902
+ c.desc 'Search query is regular expression'
903
+ c.switch %i[regex], negatable: false
904
+
905
+ c.desc 'Search query is exact text match (not tokens)'
906
+ c.switch %i[exact], negatable: false
907
+
908
+ c.desc 'Include @done actions'
909
+ c.switch %i[done]
910
+
911
+ c.desc 'Show actions not matching tags'
912
+ c.switch %i[v invert], negatable: false
913
+
914
+ c.desc 'Save this search for future use'
915
+ c.arg_name 'TITLE'
916
+ c.flag %i[save]
917
+
918
+ c.desc 'Output actions nested by file'
919
+ c.switch %[nest], negatable: false
920
+
921
+ c.desc 'Output actions nested by file and project'
922
+ c.switch %[omnifocus], negatable: false
923
+
924
+ c.action do |global_options, options, args|
925
+ options[:nest] = true if options[:omnifocus]
926
+
927
+ if options[:save]
928
+ title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
929
+ NA.save_search(title, "#{NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split(' ').map { |t| %("#{t}") }.join(' ')}")
930
+ end
931
+
932
+ depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
933
+ 3
934
+ else
935
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
936
+ end
937
+
938
+ tags = []
939
+
940
+ all_req = args.join(' ') !~ /[+!\-]/ && !options[:or]
941
+ args.join(',').split(/ *, */).each do |arg|
942
+ m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
943
+
944
+ tags.push({
945
+ tag: m['tag'].wildcard_to_rx,
946
+ comp: m['op'],
947
+ value: m['val'],
948
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
949
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
950
+ })
951
+ end
952
+
953
+ search_for_done = false
954
+ tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
955
+ tags.push({ tag: 'done', value: nil, negate: true}) unless search_for_done
956
+
957
+ tokens = nil
958
+ if options[:search]
959
+ if options[:exact]
960
+ tokens = options[:search].join(' ')
961
+ elsif options[:regex]
962
+ tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
963
+ else
964
+ tokens = []
965
+ all_req = options[:search].join(' ') !~ /[+!\-]/ && !options[:or]
966
+
967
+ options[:search].join(' ').split(/ /).each do |arg|
968
+ m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
969
+ tokens.push({
970
+ token: m['tok'],
971
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
972
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
973
+ })
974
+ end
975
+ end
976
+ end
977
+
978
+ todo = nil
979
+ if options[:in]
980
+ todo = []
981
+ options[:in].split(/ *, */).each do |a|
982
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
983
+ todo.push({
984
+ token: m['tok'],
985
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
986
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
987
+ })
988
+ end
989
+ end
990
+
991
+ files, actions, = NA.parse_actions(depth: depth,
992
+ done: options[:done],
993
+ query: todo,
994
+ search: tokens,
995
+ tag: tags,
996
+ negate: options[:invert],
997
+ project: options[:project],
998
+ require_na: false)
999
+ # regexes = tags.delete_if { |token| token[:negate] }.map { |token| token[:token] }
1000
+ regexes = if tokens.is_a?(Array)
1001
+ tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
1002
+ else
1003
+ [tokens]
1004
+ end
1005
+ NA.output_actions(actions, depth, files: files, regexes: regexes, notes: options[:notes], nest: options[:nest], nest_projects: options[:omnifocus])
1006
+ end
1007
+ end
1008
+
1009
+ desc 'Create a new todo file in the current directory'
1010
+ arg_name 'PROJECT', optional: true
1011
+ command %i[init create] do |c|
1012
+ c.example 'na init', desc: 'Generate a new todo file, prompting for project name'
1013
+ c.example 'na init warpspeed', desc: 'Generate a new todo for a project called warpspeed'
1014
+
1015
+ c.action do |global_options, _options, args|
1016
+ reader = TTY::Reader.new
1017
+ if args.count.positive?
1018
+ project = args.join(' ')
1019
+ elsif
1020
+ project = File.expand_path('.').split('/').last
1021
+ project = reader.read_line(NA::Color.template('{y}Project name {bw}> {x}'), value: project).strip if $stdin.isatty
1022
+ end
1023
+
1024
+ target = "#{project}.#{NA.extension}"
1025
+
1026
+ if File.exist?(target)
1027
+ res = NA.yn(NA::Color.template("{r}File {bw}#{target}{r} already exists, overwrite it"), default: false)
1028
+ Process.exit 1 unless res
1029
+
1030
+ end
1031
+
1032
+ NA.create_todo(target, project, template: global_options[:template])
1033
+ end
1034
+ end
1035
+
1036
+ desc 'Open a todo file in the default editor'
1037
+ long_desc 'Let the system choose the defualt, (e.g. TaskPaper), or specify a command line utility (e.g. vim).
1038
+ If more than one todo file is found, a menu is displayed.'
1039
+ command %i[edit] do |c|
1040
+ c.example 'na edit', desc: 'Open the main todo file in the default editor'
1041
+ c.example 'na edit -d 3 -a vim', desc: 'Display a menu of all todo files three levels deep from the
1042
+ current directory, open selection in vim.'
1043
+
1044
+ c.desc 'Recurse to depth'
1045
+ c.arg_name 'DEPTH'
1046
+ c.default_value 1
1047
+ c.flag %i[d depth], type: :integer, must_match: /^\d+$/
1048
+
1049
+ c.desc 'Specify an editor CLI'
1050
+ c.arg_name 'EDITOR'
1051
+ c.flag %i[e editor]
1052
+
1053
+ c.desc 'Specify a Mac app'
1054
+ c.arg_name 'EDITOR'
1055
+ c.flag %i[a app]
1056
+
1057
+ c.action do |global_options, options, args|
1058
+ depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
1059
+ 3
1060
+ else
1061
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
1062
+ end
1063
+ files = NA.find_files(depth: depth)
1064
+ files.delete_if { |f| f !~ /.*?(#{args.join('|')}).*?.#{NA.extension}/ } if args.count.positive?
1065
+
1066
+ file = if files.count > 1
1067
+ NA.select_file(files)
1068
+ else
1069
+ files[0]
1070
+ end
1071
+
1072
+ if options[:editor]
1073
+ system options[:editor], file
1074
+ else
1075
+ NA.edit_file(file: file, app: options[:app])
1076
+ end
1077
+ end
1078
+ end
1079
+
1080
+ desc 'Show list of known todo files'
1081
+ long_desc 'Arguments will be interpreted as a query against which the
1082
+ list of todos will be fuzzy matched. Separate directories with
1083
+ /, :, or a space, e.g. `na todos code/marked`'
1084
+ arg_name 'QUERY', optional: true
1085
+ command %i[todos] do |c|
1086
+ c.action do |_global_options, _options, args|
1087
+ if args.count.positive?
1088
+ all_req = args.join(' ') !~ /[+!\-]/
1089
+
1090
+ tokens = [{ token: '*', required: all_req, negate: false }]
1091
+ args.each do |arg|
1092
+ arg.split(/ *, */).each do |a|
1093
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
1094
+ tokens.push({
1095
+ token: m['tok'],
1096
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
1097
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
1098
+ })
1099
+ end
1100
+ end
1101
+ end
1102
+
1103
+ NA.list_todos(query: tokens)
1104
+ end
1105
+ end
1106
+
1107
+ desc 'Show list of projects for a file'
1108
+ long_desc 'Arguments will be interpreted as a query for a known todo file,
1109
+ fuzzy matched. Separate directories with /, :, or a space, e.g. `na projects code/marked`'
1110
+ arg_name 'QUERY', optional: true
1111
+ command %i[projects] do |c|
1112
+ c.desc 'Search for files X directories deep'
1113
+ c.arg_name 'DEPTH'
1114
+ c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
1115
+
1116
+ c.desc 'Output projects as paths instead of hierarchy'
1117
+ c.switch %i[p paths], negatable: false
1118
+
1119
+ c.action do |_global_options, options, args|
1120
+ if args.count.positive?
1121
+ all_req = args.join(' ') !~ /[+!\-]/
1122
+
1123
+ tokens = [{ token: '*', required: all_req, negate: false }]
1124
+ args.each do |arg|
1125
+ arg.split(/ *, */).each do |a|
1126
+ m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
1127
+ tokens.push({
1128
+ token: m['tok'],
1129
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
1130
+ negate: !m['req'].nil? && m['req'] =~ /[!\-]/
1131
+ })
1132
+ end
1133
+ end
1134
+ end
1135
+
1136
+ NA.list_projects(query: tokens, depth: options[:depth], paths: options[:paths])
1137
+ end
1138
+ end
1139
+
1140
+ desc 'Show or install prompt hooks for the current shell'
1141
+ long_desc 'Installing the prompt hook allows you to automatically
1142
+ list next actions when you cd into a directory'
1143
+ command %i[prompt] do |c|
1144
+ c.desc 'Output the prompt hook for the current shell to STDOUT. Pass an argument to
1145
+ specify a shell (zsh, bash, fish)'
1146
+ c.arg_name 'SHELL', optional: true
1147
+ c.command %i[show] do |s|
1148
+ s.action do |_global_options, _options, args|
1149
+ shell = if args.count.positive?
1150
+ args[0]
1151
+ else
1152
+ File.basename(ENV['SHELL'])
1153
+ end
1154
+
1155
+ case shell
1156
+ when /^f/i
1157
+ NA::Prompt.show_prompt_hook(:fish)
1158
+ when /^z/i
1159
+ NA::Prompt.show_prompt_hook(:zsh)
1160
+ when /^b/i
1161
+ NA::Prompt.show_prompt_hook(:bash)
1162
+ end
1163
+ end
1164
+ end
1165
+
1166
+ c.desc 'Install the hook for the current shell to the appropriate startup file.'
1167
+ c.arg_name 'SHELL', optional: true
1168
+ c.command %i[install] do |s|
1169
+ s.action do |_global_options, _options, args|
1170
+ shell = if args.count.positive?
1171
+ args[0]
1172
+ else
1173
+ File.basename(ENV['SHELL'])
1174
+ end
1175
+
1176
+ case shell
1177
+ when /^f/i
1178
+ NA::Prompt.install_prompt_hook(:fish)
1179
+ when /^z/i
1180
+ NA::Prompt.install_prompt_hook(:zsh)
1181
+ when /^b/i
1182
+ NA::Prompt.install_prompt_hook(:bash)
1183
+ end
1184
+ end
1185
+ end
1186
+ end
1187
+
1188
+ desc 'Display the changelog'
1189
+ command %i[changes changelog] do |c|
1190
+ c.action do |_, _, _|
1191
+ changelog = File.expand_path(File.join(File.dirname(__FILE__), '..', 'CHANGELOG.md'))
1192
+ pagers = [
1193
+ 'mdless',
1194
+ 'mdcat',
1195
+ 'bat',
1196
+ ENV['PAGER'],
1197
+ 'less -FXr',
1198
+ ENV['GIT_PAGER'],
1199
+ 'more -r'
1200
+ ]
1201
+ pager = pagers.find { |cmd| TTY::Which.exist?(cmd.split.first) }
1202
+ system %(#{pager} "#{changelog}")
1203
+ end
1204
+ end
1205
+
1206
+ desc 'Execute a saved search'
1207
+ long_desc 'Run without argument to list saved searches'
1208
+ arg_name 'SEARCH_TITLE', optional: true
1209
+ command %i[saved] do |c|
1210
+ c.example 'na tagged "+maybe,+priority<=3" --save maybelater', description: 'save a search called "maybelater"'
1211
+ c.example 'na saved maybelater', description: 'perform the search named "maybelater"'
1212
+ c.example 'na saved maybe',
1213
+ description: 'perform the search named "maybelater", assuming no other searches match "maybe"'
1214
+ c.example 'na maybe',
1215
+ description: 'na run with no command and a single argument automatically performs a matching saved search'
1216
+ c.example 'na saved', description: 'list available searches'
1217
+
1218
+ c.desc 'Open the saved search file in $EDITOR'
1219
+ c.switch %i[e edit], negatable: false
1220
+
1221
+ c.desc 'Delete the specified search definition'
1222
+ c.switch %i[d delete], negatable: false
1223
+
1224
+ c.action do |_global_options, options, args|
1225
+ NA.edit_searches if options[:edit]
1226
+
1227
+ searches = NA.load_searches
1228
+ if args.empty?
1229
+ NA.notify("{bg}Saved searches stored in {bw}#{NA.database_path(file: 'saved_searches.yml')}")
1230
+ NA.notify(searches.map { |k, v| "{y}#{k}: {w}#{v}" }.join("\n"), exit_code: 0)
1231
+ else
1232
+ NA.delete_search(args) if options[:delete]
1233
+
1234
+ keys = searches.keys.delete_if { |k| k !~ /#{args[0]}/ }
1235
+ NA.notify("{r}Search #{args[0]} not found", exit_code: 1) if keys.empty?
1236
+
1237
+ key = keys[0]
1238
+ cmd = Shellwords.shellsplit(searches[key])
1239
+ exit run(cmd)
1240
+ end
1241
+ end
1242
+ end
85
1243
 
86
1244
  pre do |global, _command, _options, _args|
87
1245
  NA.verbose = global[:debug]
@@ -128,6 +1286,7 @@ include GLI::App
128
1286
  true
129
1287
  end
130
1288
  end
1289
+ end
131
1290
 
132
1291
  NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
133
1292
  NA.stdin = nil unless NA.stdin && NA.stdin.length.positive?
@@ -145,4 +1304,4 @@ ARGV.each do |arg|
145
1304
  end
146
1305
  NA.command = NA.command_line[0]
147
1306
 
148
- exit run(ARGV)
1307
+ exit App.run(ARGV)