na 1.2.26 → 1.2.28

Sign up to get free protection for your applications and to get access to all the features.
data/bin/na CHANGED
@@ -7,8 +7,7 @@ require 'na'
7
7
  require 'fcntl'
8
8
 
9
9
  # Main application
10
- class App
11
- extend GLI::App
10
+ include GLI::App
12
11
 
13
12
  program_desc 'Add and list next actions for the current project'
14
13
 
@@ -73,1165 +72,16 @@ class App
73
72
  desc 'Display verbose output'
74
73
  switch %i[debug], default_value: false
75
74
 
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.
75
+ # def add_commands(commands)
76
+ # commands = [commands] unless commands.is_a?(Array)
77
+ # commands.each { |cmd| require_relative "commands/#{cmd}" }
78
+ # end
79
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 'Match actions containing tag. Allows value comparisons'
472
- c.arg_name 'TAG'
473
- c.flag %i[tagged], multiple: true
474
-
475
- c.desc 'Act on all matches immediately (no menu)'
476
- c.switch %i[all], negatable: false
477
-
478
- c.desc 'Interpret search pattern as regular expression'
479
- c.switch %i[e regex], negatable: false
480
-
481
- c.desc 'Match pattern exactly'
482
- c.switch %i[x exact], negatable: false
483
-
484
- c.action do |global, options, args|
485
- options[:done] = true
486
- options[:finish] = true
487
- options[:project] = 'Archive'
488
- options[:archive] = true
489
- options[:a] = true
490
- cmd = commands[:update]
491
- action = cmd.send(:get_action, nil)
492
- action.call(global, options, args)
493
- end
494
- end
495
-
496
- desc 'Update an existing action'
497
- long_desc 'Provides an easy way to complete, prioritize, and tag existing actions.
498
-
499
- If multiple todo files are found in the current directory, a menu will
500
- allow you to pick which file to act on.'
501
- arg_name 'ACTION'
502
- command %i[update] do |c|
503
- c.example 'na update --remove na "An existing task"',
504
- desc: 'Find "An existing task" action and remove the @na tag from it'
505
- c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
506
- desc: 'Find "A bug..." action, add @waiting, add/update @priority(4), and prompt for an additional note'
507
- c.example 'na update --archive My cool action',
508
- desc: 'Add @done to "My cool action" and immediately move to Archive'
509
-
510
- c.desc 'Prompt for additional notes. Input will be appended to any existing note.
511
- If STDIN input (piped) is detected, it will be used as a note.'
512
- c.switch %i[n note], negatable: false
513
-
514
- c.desc 'Overwrite note instead of appending'
515
- c.switch %i[o overwrite], negatable: false
516
-
517
- c.desc 'Add/change a priority level 1-5'
518
- c.arg_name 'PRIO'
519
- c.flag %i[p priority], must_match: /[1-5]/, type: :integer, default_value: 0
520
-
521
- c.desc 'When moving task, add at [s]tart or [e]nd of target project'
522
- c.arg_name 'POSITION'
523
- c.flag %i[at], must_match: /^[sbea].*?$/i
524
-
525
- c.desc 'Move action to specific project'
526
- c.arg_name 'PROJECT'
527
- c.flag %i[to project proj]
528
-
529
- c.desc 'Use a known todo file, partial matches allowed'
530
- c.arg_name 'TODO_FILE'
531
- c.flag %i[in todo]
532
-
533
- c.desc 'Include @done actions'
534
- c.switch %i[done]
535
-
536
- c.desc 'Add a tag to the action, @tag(values) allowed'
537
- c.arg_name 'TAG'
538
- c.flag %i[t tag], multiple: true
539
-
540
- c.desc 'Remove a tag to the action'
541
- c.arg_name 'TAG'
542
- c.flag %i[r remove], multiple: true
543
-
544
- c.desc 'Add a @done tag to action'
545
- c.switch %i[f finish], negatable: false
546
-
547
- c.desc 'Add a @done tag to action and move to Archive'
548
- c.switch %i[a archive], negatable: false
549
-
550
- c.desc 'Delete an action'
551
- c.switch %i[delete], negatable: false
552
-
553
- c.desc 'Specify the file to search for the task'
554
- c.arg_name 'PATH'
555
- c.flag %i[file]
556
-
557
- c.desc 'Search for files X directories deep'
558
- c.arg_name 'DEPTH'
559
- c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
560
-
561
- c.desc 'Match actions containing tag. Allows value comparisons'
562
- c.arg_name 'TAG'
563
- c.flag %i[tagged], multiple: true
564
-
565
- c.desc 'Act on all matches immediately (no menu)'
566
- c.switch %i[all], negatable: false
567
-
568
- c.desc 'Interpret search pattern as regular expression'
569
- c.switch %i[e regex], negatable: false
570
-
571
- c.desc 'Match pattern exactly'
572
- c.switch %i[x exact], negatable: false
573
-
574
- c.action do |global_options, options, args|
575
- reader = TTY::Reader.new
576
- append = options[:at] ? options[:at] =~ /^[ae]/i : global_options[:add_at] =~ /^[ae]/i
577
-
578
- action = if args.count.positive?
579
- args.join(' ').strip
580
- elsif $stdin.isatty && TTY::Which.exist?('gum') && options[:tagged].empty?
581
- options = [
582
- %(--placeholder "Enter a task to search for"),
583
- '--char-limit=500',
584
- "--width=#{TTY::Screen.columns}"
585
- ]
586
- `gum input #{options.join(' ')}`.strip
587
- elsif $stdin.isatty && options[:tagged].empty?
588
- puts NA::Color.template('{bm}Enter search string:{x}')
589
- reader.read_line(NA::Color.template('{by}> {bw}')).strip
590
- end
591
-
592
- if action
593
- tokens = nil
594
- if options[:exact]
595
- tokens = action
596
- elsif options[:regex]
597
- tokens = Regexp.new(action, Regexp::IGNORECASE)
598
- else
599
- tokens = []
600
- all_req = action !~ /[+!\-]/ && !options[:or]
601
-
602
- action.split(/ /).each do |arg|
603
- m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
604
- tokens.push({
605
- token: m['tok'],
606
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
607
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
608
- })
609
- end
610
- end
611
- end
612
-
613
- if (action.nil? || action.empty?) && options[:tagged].empty?
614
- puts 'Empty input, cancelled'
615
- Process.exit 1
616
- end
617
-
618
- all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
619
- tags = []
620
- options[:tagged].join(',').split(/ *, */).each do |arg|
621
- m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
622
-
623
- tags.push({
624
- tag: m['tag'].wildcard_to_rx,
625
- comp: m['op'],
626
- value: m['val'],
627
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
628
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
629
- })
630
- end
631
-
632
- priority = options[:priority].to_i if options[:priority]&.to_i&.positive?
633
- add_tags = options[:tag] ? options[:tag].map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
634
- remove_tags = options[:remove] ? options[:remove].map { |t| t.sub(/^@/, '').wildcard_to_rx } : []
635
-
636
- stdin_note = NA.stdin ? NA.stdin.split("\n") : []
637
-
638
- line_note = if options[:note] && $stdin.isatty
639
- puts stdin_note unless stdin_note.nil?
640
- if TTY::Which.exist?('gum')
641
- args = ['--placeholder "Enter a note, CTRL-d to save"']
642
- args << '--char-limit 0'
643
- args << '--width $(tput cols)'
644
- `gum write #{args.join(' ')}`.strip.split("\n")
645
- else
646
- puts NA::Color.template('{bm}Enter a note, {bw}CTRL-d{bm} to end editing{bw}')
647
- reader.read_multiline
648
- end
649
- end
650
-
651
- note = stdin_note.empty? ? [] : stdin_note
652
- note.concat(line_note) unless line_note.nil? || line_note.empty?
653
-
654
- target_proj = if options[:project]
655
- options[:project]
656
- elsif NA.cwd_is == :project
657
- NA.cwd
658
- else
659
- nil
660
- end
661
-
662
- if options[:file]
663
- file = File.expand_path(options[:file])
664
- NA.notify('{r}File not found', exit_code: 1) unless File.exist?(file)
665
-
666
- targets = [file]
667
- elsif options[:todo]
668
- todo = []
669
- options[:todo].split(/ *, */).each do |a|
670
- m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
671
- todo.push({
672
- token: m['tok'],
673
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
674
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
675
- })
676
- end
677
- dirs = NA.match_working_dir(todo)
678
-
679
- if dirs.count == 1
680
- targets = [dirs[0]]
681
- elsif dirs.count.positive?
682
- targets = NA.select_file(dirs, multiple: true)
683
- NA.notify('{r}Cancelled', exit_code: 1) unless targets && targets.count.positive?
684
- else
685
- NA.notify('{r}Todo not found', exit_code: 1) unless targets && targets.count.positive?
686
-
687
- end
688
- else
689
- files = NA.find_files(depth: options[:depth])
690
- NA.notify('{r}No todo file found', exit_code: 1) if files.count.zero?
691
-
692
- targets = files.count > 1 ? NA.select_file(files, multiple: true) : [files[0]]
693
- NA.notify('{r}Cancelled{x}', exit_code: 1) unless files.count.positive?
694
-
695
- end
696
-
697
- if options[:archive]
698
- options[:finish] = true
699
- options[:project] = 'Archive'
700
- end
701
-
702
- NA.notify('{r}No search terms provided', exit_code: 1) if tokens.nil? && options[:tagged].empty?
703
-
704
- targets.each do |target|
705
- NA.update_action(target, tokens,
706
- priority: priority,
707
- add_tag: add_tags,
708
- remove_tag: remove_tags,
709
- finish: options[:finish],
710
- project: target_proj,
711
- delete: options[:delete],
712
- note: note,
713
- overwrite: options[:overwrite],
714
- tagged: tags,
715
- all: options[:all],
716
- done: options[:done],
717
- append: append)
718
- end
719
- end
720
- end
721
-
722
- desc 'Find actions matching a search pattern'
723
- long_desc 'Search tokens are separated by spaces. Actions matching all tokens in the pattern will be shown
724
- (partial matches allowed). Add a + before a token to make it required, e.g. `na find +feature +maybe`,
725
- add a - or ! to ignore matches containing that token.'
726
- arg_name 'PATTERN'
727
- command %i[find grep] do |c|
728
- c.example 'na find feature idea swift', desc: 'Find all actions containing feature, idea, and swift'
729
- c.example 'na find feature idea -swift', desc: 'Find all actions containing feature and idea but NOT swift'
730
- c.example 'na find -x feature idea', desc: 'Find all actions containing the exact text "feature idea"'
731
-
732
- c.desc 'Interpret search pattern as regular expression'
733
- c.switch %i[e regex], negatable: false
734
-
735
- c.desc 'Match pattern exactly'
736
- c.switch %i[x exact], negatable: false
737
-
738
- c.desc 'Recurse to depth'
739
- c.arg_name 'DEPTH'
740
- c.flag %i[d depth], type: :integer, must_match: /^\d+$/
741
-
742
- c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
743
- c.arg_name 'TODO_PATH'
744
- c.flag %i[in]
745
-
746
- c.desc 'Include notes in output'
747
- c.switch %i[notes], negatable: true, default_value: false
748
-
749
- c.desc 'Combine search tokens with OR, displaying actions matching ANY of the terms'
750
- c.switch %i[o or], negatable: false
751
-
752
- c.desc 'Show actions from a specific project'
753
- c.arg_name 'PROJECT[/SUBPROJECT]'
754
- c.flag %i[proj project]
755
-
756
- c.desc 'Match actions containing tag. Allows value comparisons'
757
- c.arg_name 'TAG'
758
- c.flag %i[tagged], multiple: true
759
-
760
- c.desc 'Include @done actions'
761
- c.switch %i[done]
762
-
763
- c.desc 'Show actions not matching search pattern'
764
- c.switch %i[v invert], negatable: false
765
-
766
- c.desc 'Save this search for future use'
767
- c.arg_name 'TITLE'
768
- c.flag %i[save]
769
-
770
- c.desc 'Output actions nested by file'
771
- c.switch %[nest], negatable: false
772
-
773
- c.desc 'Output actions nested by file and project'
774
- c.switch %[omnifocus], negatable: false
775
-
776
- c.action do |global_options, options, args|
777
- options[:nest] = true if options[:omnifocus]
778
-
779
- if options[:save]
780
- title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
781
- NA.save_search(title, "#{NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split(' ').map { |t| %("#{t}") }.join(' ')}")
782
- end
783
-
784
-
785
- depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
786
- 3
787
- else
788
- options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
789
- end
790
-
791
- all_req = options[:tagged].join(' ') !~ /[+!\-]/ && !options[:or]
792
- tags = []
793
- options[:tagged].join(',').split(/ *, */).each do |arg|
794
- m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
795
-
796
- tags.push({
797
- tag: m['tag'].wildcard_to_rx,
798
- comp: m['op'],
799
- value: m['val'],
800
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
801
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
802
- })
803
- end
804
-
805
- tokens = nil
806
- if options[:exact]
807
- tokens = args.join(' ')
808
- elsif options[:regex]
809
- tokens = Regexp.new(args.join(' '), Regexp::IGNORECASE)
810
- else
811
- tokens = []
812
- all_req = args.join(' ') !~ /[+!\-]/ && !options[:or]
813
-
814
- args.join(' ').split(/ /).each do |arg|
815
- m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
816
- tokens.push({
817
- token: m['tok'],
818
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
819
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
820
- })
821
- end
822
- end
823
-
824
- todo = nil
825
- if options[:in]
826
- todo = []
827
- options[:in].split(/ *, */).each do |a|
828
- m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
829
- todo.push({
830
- token: m['tok'],
831
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
832
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
833
- })
834
- end
835
- end
836
-
837
- files, actions, = NA.parse_actions(depth: depth,
838
- done: options[:done],
839
- query: todo,
840
- search: tokens,
841
- tag: tags,
842
- negate: options[:invert],
843
- regex: options[:regex],
844
- project: options[:project],
845
- require_na: false)
846
- regexes = if tokens.is_a?(Array)
847
- tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
848
- else
849
- [tokens]
850
- end
851
-
852
- NA.output_actions(actions, depth, files: files, regexes: regexes, notes: options[:notes], nest: options[:nest], nest_projects: options[:omnifocus])
853
- end
854
- end
855
-
856
- desc 'Find actions matching a tag'
857
- long_desc 'Finds actions with tags matching the arguments. An action is shown if it
858
- contains all of the tags listed. Add a + before a tag to make it required
859
- and others optional. You can specify values using TAG=VALUE pairs.
860
- Use <, >, and = for numeric comparisons, and *=, ^=, and $= for text comparisons.
861
- Date comparisons use natural language (`na tagged "due<=today"`) and
862
- are detected automatically.'
863
- arg_name 'TAG[=VALUE]'
864
- command %i[tagged] do |c|
865
- c.example 'na tagged maybe', desc: 'Show all actions tagged @maybe'
866
- c.example 'na tagged -d 3 "feature, idea"', desc: 'Show all actions tagged @feature AND @idea, recurse 3 levels'
867
- c.example 'na tagged --or "feature, idea"', desc: 'Show all actions tagged @feature OR @idea'
868
- c.example 'na tagged "priority>=4"', desc: 'Show actions with @priority(4) or @priority(5)'
869
- c.example 'na tagged "due<in 2 days"', desc: 'Show actions with a due date coming up in the next 2 days'
870
-
871
- c.desc 'Recurse to depth'
872
- c.arg_name 'DEPTH'
873
- c.default_value 1
874
- c.flag %i[d depth], type: :integer, must_match: /^\d+$/
875
-
876
- c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
877
- c.arg_name 'TODO_PATH'
878
- c.flag %i[in]
879
-
880
- c.desc 'Include notes in output'
881
- c.switch %i[notes], negatable: true, default_value: false
882
-
883
- c.desc 'Combine tags with OR, displaying actions matching ANY of the tags'
884
- c.switch %i[o or], negatable: false
885
-
886
- c.desc 'Show actions from a specific project'
887
- c.arg_name 'PROJECT[/SUBPROJECT]'
888
- c.flag %i[proj project]
889
-
890
- c.desc 'Filter results using search terms'
891
- c.arg_name 'QUERY'
892
- c.flag %i[search], multiple: true
893
-
894
- c.desc 'Search query is regular expression'
895
- c.switch %i[regex], negatable: false
896
-
897
- c.desc 'Search query is exact text match (not tokens)'
898
- c.switch %i[exact], negatable: false
899
-
900
- c.desc 'Include @done actions'
901
- c.switch %i[done]
902
-
903
- c.desc 'Show actions not matching tags'
904
- c.switch %i[v invert], negatable: false
905
-
906
- c.desc 'Save this search for future use'
907
- c.arg_name 'TITLE'
908
- c.flag %i[save]
909
-
910
- c.desc 'Output actions nested by file'
911
- c.switch %[nest], negatable: false
912
-
913
- c.desc 'Output actions nested by file and project'
914
- c.switch %[omnifocus], negatable: false
915
-
916
- c.action do |global_options, options, args|
917
- options[:nest] = true if options[:omnifocus]
918
-
919
- if options[:save]
920
- title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
921
- NA.save_search(title, "#{NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split(' ').map { |t| %("#{t}") }.join(' ')}")
922
- end
923
-
924
- depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
925
- 3
926
- else
927
- options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
928
- end
929
-
930
- tags = []
931
-
932
- all_req = args.join(' ') !~ /[+!\-]/ && !options[:or]
933
- args.join(',').split(/ *, */).each do |arg|
934
- m = arg.match(/^(?<req>[+\-!])?(?<tag>[^ =<>$\^]+?)(?:(?<op>[=<>]{1,2}|[*$\^]=)(?<val>.*?))?$/)
935
-
936
- tags.push({
937
- tag: m['tag'].wildcard_to_rx,
938
- comp: m['op'],
939
- value: m['val'],
940
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
941
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
942
- })
943
- end
944
-
945
- search_for_done = false
946
- tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
947
- tags.push({ tag: 'done', value: nil, negate: true}) unless search_for_done
948
-
949
- tokens = nil
950
- if options[:search]
951
- if options[:exact]
952
- tokens = options[:search].join(' ')
953
- elsif options[:regex]
954
- tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
955
- else
956
- tokens = []
957
- all_req = options[:search].join(' ') !~ /[+!\-]/ && !options[:or]
958
-
959
- options[:search].join(' ').split(/ /).each do |arg|
960
- m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
961
- tokens.push({
962
- token: m['tok'],
963
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
964
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
965
- })
966
- end
967
- end
968
- end
969
-
970
- todo = nil
971
- if options[:in]
972
- todo = []
973
- options[:in].split(/ *, */).each do |a|
974
- m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
975
- todo.push({
976
- token: m['tok'],
977
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
978
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
979
- })
980
- end
981
- end
982
-
983
- files, actions, = NA.parse_actions(depth: depth,
984
- done: options[:done],
985
- query: todo,
986
- search: tokens,
987
- tag: tags,
988
- negate: options[:invert],
989
- project: options[:project],
990
- require_na: false)
991
- # regexes = tags.delete_if { |token| token[:negate] }.map { |token| token[:token] }
992
- regexes = if tokens.is_a?(Array)
993
- tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
994
- else
995
- [tokens]
996
- end
997
- NA.output_actions(actions, depth, files: files, regexes: regexes, notes: options[:notes], nest: options[:nest], nest_projects: options[:omnifocus])
998
- end
999
- end
1000
-
1001
- desc 'Create a new todo file in the current directory'
1002
- arg_name 'PROJECT', optional: true
1003
- command %i[init create] do |c|
1004
- c.example 'na init', desc: 'Generate a new todo file, prompting for project name'
1005
- c.example 'na init warpspeed', desc: 'Generate a new todo for a project called warpspeed'
1006
-
1007
- c.action do |global_options, _options, args|
1008
- reader = TTY::Reader.new
1009
- if args.count.positive?
1010
- project = args.join(' ')
1011
- elsif
1012
- project = File.expand_path('.').split('/').last
1013
- project = reader.read_line(NA::Color.template('{y}Project name {bw}> {x}'), value: project).strip if $stdin.isatty
1014
- end
1015
-
1016
- target = "#{project}.#{NA.extension}"
1017
-
1018
- if File.exist?(target)
1019
- res = NA.yn(NA::Color.template("{r}File {bw}#{target}{r} already exists, overwrite it"), default: false)
1020
- Process.exit 1 unless res
1021
-
1022
- end
1023
-
1024
- NA.create_todo(target, project, template: global_options[:template])
1025
- end
1026
- end
1027
-
1028
- desc 'Open a todo file in the default editor'
1029
- long_desc 'Let the system choose the defualt, (e.g. TaskPaper), or specify a command line utility (e.g. vim).
1030
- If more than one todo file is found, a menu is displayed.'
1031
- command %i[edit] do |c|
1032
- c.example 'na edit', desc: 'Open the main todo file in the default editor'
1033
- c.example 'na edit -d 3 -a vim', desc: 'Display a menu of all todo files three levels deep from the
1034
- current directory, open selection in vim.'
1035
-
1036
- c.desc 'Recurse to depth'
1037
- c.arg_name 'DEPTH'
1038
- c.default_value 1
1039
- c.flag %i[d depth], type: :integer, must_match: /^\d+$/
1040
-
1041
- c.desc 'Specify an editor CLI'
1042
- c.arg_name 'EDITOR'
1043
- c.flag %i[e editor]
1044
-
1045
- c.desc 'Specify a Mac app'
1046
- c.arg_name 'EDITOR'
1047
- c.flag %i[a app]
1048
-
1049
- c.action do |global_options, options, args|
1050
- depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
1051
- 3
1052
- else
1053
- options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
1054
- end
1055
- files = NA.find_files(depth: depth)
1056
- files.delete_if { |f| f !~ /.*?(#{args.join('|')}).*?.#{NA.extension}/ } if args.count.positive?
1057
-
1058
- file = if files.count > 1
1059
- NA.select_file(files)
1060
- else
1061
- files[0]
1062
- end
1063
-
1064
- if options[:editor]
1065
- system options[:editor], file
1066
- else
1067
- NA.edit_file(file: file, app: options[:app])
1068
- end
1069
- end
1070
- end
1071
-
1072
- desc 'Show list of known todo files'
1073
- long_desc 'Arguments will be interpreted as a query against which the
1074
- list of todos will be fuzzy matched. Separate directories with
1075
- /, :, or a space, e.g. `na todos code/marked`'
1076
- arg_name 'QUERY', optional: true
1077
- command %i[todos] do |c|
1078
- c.action do |_global_options, _options, args|
1079
- if args.count.positive?
1080
- all_req = args.join(' ') !~ /[+!\-]/
1081
-
1082
- tokens = [{ token: '*', required: all_req, negate: false }]
1083
- args.each do |arg|
1084
- arg.split(/ *, */).each do |a|
1085
- m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
1086
- tokens.push({
1087
- token: m['tok'],
1088
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
1089
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
1090
- })
1091
- end
1092
- end
1093
- end
1094
-
1095
- NA.list_todos(query: tokens)
1096
- end
1097
- end
1098
-
1099
- desc 'Show list of projects for a file'
1100
- long_desc 'Arguments will be interpreted as a query for a known todo file,
1101
- fuzzy matched. Separate directories with /, :, or a space, e.g. `na projects code/marked`'
1102
- arg_name 'QUERY', optional: true
1103
- command %i[projects] do |c|
1104
- c.desc 'Search for files X directories deep'
1105
- c.arg_name 'DEPTH'
1106
- c.flag %i[d depth], must_match: /^[1-9]$/, type: :integer, default_value: 1
1107
-
1108
- c.desc 'Output projects as paths instead of hierarchy'
1109
- c.switch %i[p paths], negatable: false
1110
-
1111
- c.action do |_global_options, options, args|
1112
- if args.count.positive?
1113
- all_req = args.join(' ') !~ /[+!\-]/
1114
-
1115
- tokens = [{ token: '*', required: all_req, negate: false }]
1116
- args.each do |arg|
1117
- arg.split(/ *, */).each do |a|
1118
- m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
1119
- tokens.push({
1120
- token: m['tok'],
1121
- required: all_req || (!m['req'].nil? && m['req'] == '+'),
1122
- negate: !m['req'].nil? && m['req'] =~ /[!\-]/
1123
- })
1124
- end
1125
- end
1126
- end
1127
-
1128
- NA.list_projects(query: tokens, depth: options[:depth], paths: options[:paths])
1129
- end
1130
- end
1131
-
1132
- desc 'Show or install prompt hooks for the current shell'
1133
- long_desc 'Installing the prompt hook allows you to automatically
1134
- list next actions when you cd into a directory'
1135
- command %i[prompt] do |c|
1136
- c.desc 'Output the prompt hook for the current shell to STDOUT. Pass an argument to
1137
- specify a shell (zsh, bash, fish)'
1138
- c.arg_name 'SHELL', optional: true
1139
- c.command %i[show] do |s|
1140
- s.action do |_global_options, _options, args|
1141
- shell = if args.count.positive?
1142
- args[0]
1143
- else
1144
- File.basename(ENV['SHELL'])
1145
- end
1146
-
1147
- case shell
1148
- when /^f/i
1149
- NA::Prompt.show_prompt_hook(:fish)
1150
- when /^z/i
1151
- NA::Prompt.show_prompt_hook(:zsh)
1152
- when /^b/i
1153
- NA::Prompt.show_prompt_hook(:bash)
1154
- end
1155
- end
1156
- end
1157
-
1158
- c.desc 'Install the hook for the current shell to the appropriate startup file.'
1159
- c.arg_name 'SHELL', optional: true
1160
- c.command %i[install] do |s|
1161
- s.action do |_global_options, _options, args|
1162
- shell = if args.count.positive?
1163
- args[0]
1164
- else
1165
- File.basename(ENV['SHELL'])
1166
- end
1167
-
1168
- case shell
1169
- when /^f/i
1170
- NA::Prompt.install_prompt_hook(:fish)
1171
- when /^z/i
1172
- NA::Prompt.install_prompt_hook(:zsh)
1173
- when /^b/i
1174
- NA::Prompt.install_prompt_hook(:bash)
1175
- end
1176
- end
1177
- end
1178
- end
1179
-
1180
- desc 'Display the changelog'
1181
- command %i[changes changelog] do |c|
1182
- c.action do |_, _, _|
1183
- changelog = File.expand_path(File.join(File.dirname(__FILE__), '..', 'CHANGELOG.md'))
1184
- pagers = [
1185
- 'mdless',
1186
- 'mdcat',
1187
- 'bat',
1188
- ENV['PAGER'],
1189
- 'less -FXr',
1190
- ENV['GIT_PAGER'],
1191
- 'more -r'
1192
- ]
1193
- pager = pagers.find { |cmd| TTY::Which.exist?(cmd.split.first) }
1194
- system %(#{pager} "#{changelog}")
1195
- end
1196
- end
1197
-
1198
- desc 'Execute a saved search'
1199
- long_desc 'Run without argument to list saved searches'
1200
- arg_name 'SEARCH_TITLE', optional: true
1201
- command %i[saved] do |c|
1202
- c.example 'na tagged "+maybe,+priority<=3" --save maybelater', description: 'save a search called "maybelater"'
1203
- c.example 'na saved maybelater', description: 'perform the search named "maybelater"'
1204
- c.example 'na saved maybe',
1205
- description: 'perform the search named "maybelater", assuming no other searches match "maybe"'
1206
- c.example 'na maybe',
1207
- description: 'na run with no command and a single argument automatically performs a matching saved search'
1208
- c.example 'na saved', description: 'list available searches'
1209
-
1210
- c.desc 'Open the saved search file in $EDITOR'
1211
- c.switch %i[e edit], negatable: false
1212
-
1213
- c.desc 'Delete the specified search definition'
1214
- c.switch %i[d delete], negatable: false
1215
-
1216
- c.action do |_global_options, options, args|
1217
- NA.edit_searches if options[:edit]
1218
-
1219
- searches = NA.load_searches
1220
- if args.empty?
1221
- NA.notify("{bg}Saved searches stored in {bw}#{NA.database_path(file: 'saved_searches.yml')}")
1222
- NA.notify(searches.map { |k, v| "{y}#{k}: {w}#{v}" }.join("\n"), exit_code: 0)
1223
- else
1224
- NA.delete_search(args) if options[:delete]
1225
-
1226
- keys = searches.keys.delete_if { |k| k !~ /#{args[0]}/ }
1227
- NA.notify("{r}Search #{args[0]} not found", exit_code: 1) if keys.empty?
1228
-
1229
- key = keys[0]
1230
- cmd = Shellwords.shellsplit(searches[key])
1231
- exit run(cmd)
1232
- end
1233
- end
1234
- end
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')
1235
85
 
1236
86
  pre do |global, _command, _options, _args|
1237
87
  NA.verbose = global[:debug]
@@ -1278,7 +128,6 @@ class App
1278
128
  true
1279
129
  end
1280
130
  end
1281
- end
1282
131
 
1283
132
  NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
1284
133
  NA.stdin = nil unless NA.stdin && NA.stdin.length.positive?
@@ -1296,4 +145,4 @@ ARGV.each do |arg|
1296
145
  end
1297
146
  NA.command = NA.command_line[0]
1298
147
 
1299
- exit App.run(ARGV)
148
+ exit run(ARGV)