na 1.2.29 → 1.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/na CHANGED
@@ -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]