na 1.2.29 → 1.2.31

Sign up to get free protection for your applications and to get access to all the features.
data/bin/na CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  $LOAD_PATH.unshift File.join(__dir__, '..', 'lib')
5
5
  require 'gli'
6
+ require 'na/help_monkey_patch'
6
7
  require 'na'
7
8
  require 'fcntl'
8
9
 
@@ -29,6 +30,9 @@ class App
29
30
  arg_name 'TAG'
30
31
  flag %i[t na_tag]
31
32
 
33
+ desc 'Enable pagination'
34
+ switch %i[pager], default_value: true, negatable: true
35
+
32
36
  default_command :next
33
37
 
34
38
  NA::Color.coloring = $stdin.isatty
@@ -73,1176 +77,13 @@ class App
73
77
  desc 'Display verbose output'
74
78
  switch %i[debug], default_value: false
75
79
 
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
-
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
80
+ Dir.glob('bin/commands/*.rb').each do |cmd|
81
+ require_relative "commands/#{File.basename(cmd, '.rb')}"
1242
82
  end
1243
83
 
1244
84
  pre do |global, _command, _options, _args|
1245
85
  NA.verbose = global[:debug]
86
+ NA::Pager.paginate = global[:pager]
1246
87
  NA::Color.coloring = global[:color]
1247
88
  NA.extension = global[:ext]
1248
89
  NA.na_tag = global[:na_tag]