na 1.2.85 → 1.2.87

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.
@@ -2,124 +2,152 @@
2
2
 
3
3
  class App
4
4
  extend GLI::App
5
- desc "Find actions matching a tag"
5
+ desc 'Find actions matching a tag'
6
6
  long_desc 'Finds actions with tags matching the arguments. An action is shown if it
7
7
  contains all of the tags listed. Add a + before a tag to make it required
8
8
  and others optional. You can specify values using TAG=VALUE pairs.
9
9
  Use <, >, and = for numeric comparisons, and *=, ^=, $=, or =~ (regex) for text comparisons.
10
10
  Date comparisons use natural language (`na tagged "due<=today"`) and
11
11
  are detected automatically.'
12
- arg_name "TAG[=VALUE]"
12
+ arg_name 'TAG[=VALUE]'
13
13
  command %i[tagged] do |c|
14
- c.example "na tagged maybe", desc: "Show all actions tagged @maybe"
15
- c.example 'na tagged -d 3 "feature, idea"', desc: "Show all actions tagged @feature AND @idea, recurse 3 levels"
16
- c.example 'na tagged --or "feature, idea"', desc: "Show all actions tagged @feature OR @idea"
17
- c.example 'na tagged "priority>=4"', desc: "Show actions with @priority(4) or @priority(5)"
18
- c.example 'na tagged "due<in 2 days"', desc: "Show actions with a due date coming up in the next 2 days"
19
-
20
- c.desc "Recurse to depth"
21
- c.arg_name "DEPTH"
14
+ c.example 'na tagged maybe', desc: 'Show all actions tagged @maybe'
15
+ c.example 'na tagged -d 3 "feature, idea"', desc: 'Show all actions tagged @feature AND @idea, recurse 3 levels'
16
+ c.example 'na tagged --or "feature, idea"', desc: 'Show all actions tagged @feature OR @idea'
17
+ c.example 'na tagged "priority>=4"', desc: 'Show actions with @priority(4) or @priority(5)'
18
+ c.example 'na tagged "due<in 2 days"', desc: 'Show actions with a due date coming up in the next 2 days'
19
+
20
+ c.desc 'Recurse to depth'
21
+ c.arg_name 'DEPTH'
22
22
  c.default_value 1
23
23
  c.flag %i[d depth], type: :integer, must_match: /^\d+$/
24
24
 
25
- c.desc "Show actions from a specific todo file in history. May use wildcards (* and ?)"
26
- c.arg_name "TODO_PATH"
25
+ c.desc 'Show actions from a specific todo file in history. May use wildcards (* and ?)'
26
+ c.arg_name 'TODO_PATH'
27
27
  c.flag %i[in]
28
28
 
29
- c.desc "Include notes in output"
29
+ c.desc 'Include notes in output'
30
30
  c.switch %i[notes], negatable: true, default_value: false
31
31
 
32
- c.desc "Combine tags with OR, displaying actions matching ANY of the tags"
32
+ c.desc 'Show per-action durations and total'
33
+ c.switch %i[times], negatable: false
34
+
35
+ c.desc 'Format durations in human-friendly form'
36
+ c.switch %i[human], negatable: false
37
+
38
+ c.desc 'Show only actions that have a duration (@started and @done)'
39
+ c.switch %i[only_timed], negatable: false
40
+
41
+ c.desc 'Output times as JSON object (implies --times and --done)'
42
+ c.switch %i[json_times], negatable: false
43
+
44
+ c.desc 'Output only elapsed time totals (implies --times and --done)'
45
+ c.switch %i[only_times], negatable: false
46
+
47
+ c.desc 'Combine tags with OR, displaying actions matching ANY of the tags'
33
48
  c.switch %i[o or], negatable: false
34
49
 
35
- c.desc "Show actions from a specific project"
36
- c.arg_name "PROJECT[/SUBPROJECT]"
50
+ c.desc 'Show actions from a specific project'
51
+ c.arg_name 'PROJECT[/SUBPROJECT]'
37
52
  c.flag %i[proj project]
38
53
 
39
- c.desc "Filter results using search terms"
40
- c.arg_name "QUERY"
54
+ c.desc 'Filter results using search terms'
55
+ c.arg_name 'QUERY'
41
56
  c.flag %i[search find grep], multiple: true
42
57
 
43
- c.desc "Include notes in search"
58
+ c.desc 'Include notes in search'
44
59
  c.switch %i[search_notes], negatable: true, default_value: true
45
60
 
46
- c.desc "Search query is regular expression"
61
+ c.desc 'Search query is regular expression'
47
62
  c.switch %i[regex], negatable: false
48
63
 
49
- c.desc "Search query is exact text match (not tokens)"
64
+ c.desc 'Search query is exact text match (not tokens)'
50
65
  c.switch %i[exact], negatable: false
51
66
 
52
- c.desc "Include @done actions"
67
+ c.desc 'Include @done actions'
53
68
  c.switch %i[done]
54
69
 
55
- c.desc "Show actions not matching tags"
70
+ c.desc 'Show actions not matching tags'
56
71
  c.switch %i[v invert], negatable: false
57
72
 
58
- c.desc "Save this search for future use"
59
- c.arg_name "TITLE"
73
+ c.desc 'Save this search for future use'
74
+ c.arg_name 'TITLE'
60
75
  c.flag %i[save]
61
76
 
62
- c.desc "Output actions nested by file"
77
+ c.desc 'Output actions nested by file'
63
78
  c.switch %i[nest], negatable: false
64
79
 
65
- c.desc "No filename in output"
80
+ c.desc 'No filename in output'
66
81
  c.switch %i[no_file], negatable: false
67
82
 
68
- c.desc "Output actions nested by file and project"
83
+ c.desc 'Output actions nested by file and project'
69
84
  c.switch %i[omnifocus], negatable: false
70
85
 
71
86
  c.action do |global_options, options, args|
72
87
  options[:nest] = true if options[:omnifocus]
73
88
 
74
89
  if options[:save]
75
- title = options[:save].gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_")
76
- cmd = NA.command_line.join(" ").sub(/ --save[= ]*\S+/, "").split(" ").map { |t| %("#{t}") }.join(" ")
90
+ title = options[:save].gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
91
+ cmd = NA.command_line.join(' ').sub(/ --save[= ]*\S+/, '').split.map { |t| %("#{t}") }.join(' ')
77
92
  NA.save_search(title, cmd)
78
93
  end
79
94
 
80
95
  depth = if global_options[:recurse] && options[:depth].nil? && global_options[:depth] == 1
81
- 3
82
- else
83
- options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
84
- end
96
+ 3
97
+ else
98
+ options[:depth].nil? ? global_options[:depth].to_i : options[:depth].to_i
99
+ end
85
100
 
86
101
  tags = []
87
102
 
88
- all_req = args.join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
89
- args.join(",").split(/ *, */).each do |arg|
103
+ all_req = args.join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
104
+ args.join(',').split(/ *, */).each do |arg|
90
105
  m = arg.match(/^(?<req>[+!-])?(?<tag>[^ =<>$~\^]+?) *(?:(?<op>[=<>~]{1,2}|[*$\^]=) *(?<val>.*?))?$/)
91
106
  next if m.nil?
92
107
 
93
108
  tags.push({
94
- tag: m["tag"].sub(/^@/, "").wildcard_to_rx,
95
- comp: m["op"],
96
- value: m["val"],
97
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
98
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
109
+ tag: m['tag'].sub(/^@/, '').wildcard_to_rx,
110
+ comp: m['op'],
111
+ value: m['val'],
112
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
113
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
99
114
  })
100
115
  end
101
116
 
102
117
  search_for_done = false
103
118
  tags.each { |tag| search_for_done = true if tag[:tag] =~ /done/ }
104
- tags.push({ tag: "done", value: nil, negate: true }) unless search_for_done || options[:done]
105
- options[:done] = true if search_for_done
119
+ if options[:json_times]
120
+ options[:times] = true
121
+ options[:done] = true
122
+ elsif options[:only_times]
123
+ options[:times] = true
124
+ options[:done] = true
125
+ elsif options[:only_timed]
126
+ options[:times] = true
127
+ options[:done] = true
128
+ elsif options[:times]
129
+ options[:done] = true
130
+ else
131
+ tags.push({ tag: 'done', value: nil, negate: true }) unless search_for_done || options[:done]
132
+ options[:done] = true if search_for_done
133
+ end
106
134
 
107
135
  tokens = nil
108
136
  if options[:search]
109
137
  if options[:exact]
110
- tokens = options[:search].join(" ")
138
+ tokens = options[:search].join(' ')
111
139
  elsif options[:regex]
112
- tokens = Regexp.new(options[:search].join(" "), Regexp::IGNORECASE)
140
+ tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
113
141
  else
114
142
  tokens = []
115
- all_req = options[:search].join(" ") !~ /(?<=[, ])[+!-]/ && !options[:or]
143
+ all_req = options[:search].join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
116
144
 
117
- options[:search].join(" ").split(/ /).each do |arg|
145
+ options[:search].join(' ').split(/ /).each do |arg|
118
146
  m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
119
147
  tokens.push({
120
- token: m["tok"],
121
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
122
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
148
+ token: m['tok'],
149
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
150
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
123
151
  })
124
152
  end
125
153
  end
@@ -132,9 +160,9 @@ class App
132
160
  options[:in].split(/ *, */).each do |a|
133
161
  m = a.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
134
162
  todos.push({
135
- token: m["tok"],
136
- required: all_req || (!m["req"].nil? && m["req"] == "+"),
137
- negate: !m["req"].nil? && m["req"] =~ /[!-]/,
163
+ token: m['tok'],
164
+ required: all_req || (!m['req'].nil? && m['req'] == '+'),
165
+ negate: !m['req'].nil? && m['req'] =~ /[!-]/
138
166
  })
139
167
  end
140
168
  end
@@ -152,17 +180,22 @@ class App
152
180
  require_na: false })
153
181
 
154
182
  regexes = if tokens.is_a?(Array)
155
- tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
156
- else
157
- [tokens]
158
- end
183
+ tokens.delete_if { |token| token[:negate] }.map { |token| token[:token] }
184
+ else
185
+ [tokens]
186
+ end
159
187
  todo.actions.output(depth,
160
188
  { files: todo.files,
161
189
  regexes: regexes,
162
190
  notes: options[:notes],
163
191
  nest: options[:nest],
164
192
  nest_projects: options[:omnifocus],
165
- no_files: options[:no_file] })
193
+ no_files: options[:no_file],
194
+ times: options[:times],
195
+ human: options[:human],
196
+ only_timed: options[:only_timed],
197
+ json_times: options[:json_times],
198
+ only_times: options[:only_times] })
166
199
  end
167
200
  end
168
201
  end
@@ -9,6 +9,17 @@ class App
9
9
  allow you to pick which file to act on.'
10
10
  arg_name 'ACTION'
11
11
  command %i[update] do |c|
12
+ c.desc 'Started time (natural language or ISO)'
13
+ c.arg_name 'DATE'
14
+ c.flag %i[started], type: :date_begin
15
+
16
+ c.desc 'End/Finished time (natural language or ISO)'
17
+ c.arg_name 'DATE'
18
+ c.flag %i[end finished], type: :date_end
19
+
20
+ c.desc 'Duration (e.g. 45m, 2h, 1d2h30m, or minutes)'
21
+ c.arg_name 'DURATION'
22
+ c.flag %i[duration], type: :duration
12
23
  c.example 'na update --remove na "An existing task"',
13
24
  desc: 'Find "An existing task" action and remove the @na tag from it'
14
25
  c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
@@ -164,13 +175,33 @@ class App
164
175
 
165
176
  options[:exact] = true unless options[:replace].nil?
166
177
 
178
+ # Check for PATH:LINE format in arguments
179
+ target_file = nil
180
+ target_line = nil
181
+ if args.count.positive?
182
+ pathline_match = args.join(' ').strip.match(/^(.+):(\d+)$/)
183
+ if pathline_match
184
+ target_file = pathline_match[1]
185
+ target_line = pathline_match[2].to_i
186
+
187
+ # Verify file exists
188
+ if File.exist?(target_file)
189
+ options[:file] = target_file
190
+ options[:target_line] = target_line
191
+ action = nil # Skip search processing
192
+ else
193
+ NA.notify("#{NA.theme[:error]}File not found: #{target_file}", exit_code: 1)
194
+ end
195
+ end
196
+ end
197
+
167
198
  if args.count.positive?
168
199
  action = args.join(' ').strip
169
200
  else
170
201
  action = nil
171
202
  end
172
203
  tokens = nil
173
- if action && !action.empty?
204
+ if action && !action.empty? && !target_line
174
205
  if options[:exact]
175
206
  tokens = action
176
207
  elsif options[:regex]
@@ -218,10 +249,11 @@ class App
218
249
  done: options[:done]
219
250
  })
220
251
  todo.actions.each do |action_obj|
221
- # Format: filename:project:parent > action
222
- display = "#{File.basename(action_obj.file)}:#{action_obj.project}:#{action_obj.parent.join('>')} | #{action_obj.action}"
252
+ # Format: filename:LINENUM:parent > action
253
+ # Include line number in display for unique matching
254
+ display = "#{File.basename(action_obj.file_path)}:#{action_obj.file_line}:#{action_obj.parent.join('>')} | #{action_obj.action}"
223
255
  candidate_actions << display
224
- targets_for_selection << { file: action_obj.file, line: action_obj.line, action: action_obj }
256
+ targets_for_selection << { file: action_obj.file_path, line: action_obj.file_line, action: action_obj }
225
257
  end
226
258
  end
227
259
 
@@ -237,9 +269,24 @@ class App
237
269
  if selector
238
270
  require 'open3'
239
271
  input = candidate_actions.join("\n")
240
- output, _ = Open3.capture2("echo \"#{input.gsub('"', '\"')}\" | #{selector}")
241
- selected = output.split("\n").map(&:strip).reject(&:empty?)
242
- selected_indices = candidate_actions.each_index.select { |i| selected.include?(candidate_actions[i]) }
272
+
273
+ # Use popen3 to properly handle stdin for fzf
274
+ Open3.popen3(selector) do |stdin, stdout, stderr, wait_thr|
275
+ stdin.write(input)
276
+ stdin.close
277
+
278
+ output = stdout.read
279
+
280
+ selected = output.split("\n").map(&:strip).reject(&:empty?)
281
+
282
+ # Track which candidates have been matched to avoid duplicates
283
+ selected_indices = []
284
+ candidate_actions.each_index do |i|
285
+ if selected.include?(candidate_actions[i])
286
+ selected_indices << i unless selected_indices.include?(i)
287
+ end
288
+ end
289
+ end
243
290
  else
244
291
  # Fallback: select all or prompt for search string
245
292
  selected_indices = (0...candidate_actions.size).to_a
@@ -263,7 +310,10 @@ class App
263
310
  options[:archive],
264
311
  options[:restore],
265
312
  options[:delete],
266
- options[:edit]
313
+ options[:edit],
314
+ options[:started],
315
+ (options[:end] || options[:finished]),
316
+ options[:duration]
267
317
  ].any?
268
318
  unless actionable
269
319
  # Interactive menu for actions
@@ -325,13 +375,23 @@ class App
325
375
  options[:delete] = true
326
376
  when :finish
327
377
  options[:finish] = true
378
+ # Timed finish? Prompt user for optional start/date inputs
379
+ if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
380
+ # Ask for start date expression
381
+ start_expr = nil
382
+ if TTY::Which.exist?('gum')
383
+ gum = TTY::Which.which('gum')
384
+ prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
385
+ start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
386
+ else
387
+ print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
388
+ start_expr = (STDIN.gets || '').strip
389
+ end
390
+ start_time = NA::Types.parse_date_begin(start_expr)
391
+ options[:started] = start_time if start_time
392
+ end
328
393
  when :edit
329
- # Open editor for the selected action and update its content
330
- edit_action = targets_for_selection[selected_indices.first][:action]
331
- editor_content = "#{edit_action.action}\n#{edit_action.note.join("\n")}"
332
- new_action, new_note = NA::Editor.format_input(NA::Editor.fork_editor(editor_content))
333
- edit_action.action = new_action
334
- edit_action.note = new_note
394
+ # Just set the flag - multi-action editor will handle it below
335
395
  options[:edit] = true
336
396
  when :priority
337
397
  options[:priority] = param_value
@@ -384,7 +444,17 @@ class App
384
444
  end
385
445
  end
386
446
  did_direct_update = false
447
+
448
+ # Group selected actions by file for batch processing
449
+ actions_by_file = {}
387
450
  selected_indices.each do |idx|
451
+ file = targets_for_selection[idx][:file]
452
+ actions_by_file[file] ||= []
453
+ actions_by_file[file] << targets_for_selection[idx][:action]
454
+ end
455
+
456
+ # Process each file's actions
457
+ actions_by_file.each do |file, action_list|
388
458
  # Rebuild all derived variables from options after menu-driven assignment
389
459
  add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
390
460
  remove_tags = options[:remove] ? options[:remove].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
@@ -399,29 +469,64 @@ class App
399
469
  if options[:note] && defined?(param_value) && param_value
400
470
  note_val = [param_value]
401
471
  end
402
- # Pass the selected action object as 'add', set search to nil
403
- # Pass the exact selected action object to update_action, bypassing all search/filter logic
404
- target = targets_for_selection[idx][:file]
405
- action_obj = targets_for_selection[idx][:action]
406
- # Direct action mode: update only the selected action in the known file
407
- NA.update_action(target, nil,
408
- add: action_obj,
409
- add_tag: add_tags,
410
- all: true,
411
- append: append,
412
- delete: options[:delete],
413
- done: options[:done],
414
- edit: options[:edit],
415
- finish: options[:finish],
416
- move: target_proj,
417
- note: note_val,
418
- overwrite: options[:overwrite],
419
- priority: priority,
420
- project: options[:project],
421
- remove_tag: remove_tags,
422
- replace: options[:replace],
423
- search_note: options[:search_notes],
424
- tagged: nil)
472
+
473
+ # Handle edit with multiple actions
474
+ if options[:edit]
475
+ # Open editor once with all actions for this file
476
+ editor_content = NA::Editor.format_multi_action_input(action_list)
477
+ edited_content = NA::Editor.fork_editor(editor_content)
478
+ edited_actions = NA::Editor.parse_multi_action_output(edited_content)
479
+
480
+ # If markers were removed but we have the same number of actions, match by position
481
+ if edited_actions.empty? && action_list.size > 0
482
+ # Parse content line by line, skipping comments and blanks
483
+ non_comment_lines = edited_content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
484
+
485
+ # Match each non-comment line to an action by position
486
+ action_list.each_with_index do |action_obj, idx|
487
+ if non_comment_lines[idx]
488
+ # Split into action and notes
489
+ lines = non_comment_lines[idx..-1]
490
+ action_text = lines[0]
491
+ note_lines = lines[1..-1] || []
492
+
493
+ # Store by file:line key
494
+ key = "#{action_obj.file_path}:#{action_obj.file_line}"
495
+ edited_actions[key] = [action_text, note_lines]
496
+ end
497
+ end
498
+ end
499
+
500
+ # Update each action with edited content
501
+ action_list.each do |action_obj|
502
+ key = "#{action_obj.file_path}:#{action_obj.file_line}"
503
+ if edited_actions[key]
504
+ action_obj.action, action_obj.note = edited_actions[key]
505
+ end
506
+ end
507
+ end
508
+
509
+ # Update each action (process from bottom to top to avoid line shifts)
510
+ action_list.sort_by(&:file_line).reverse.each do |action_obj|
511
+ NA.update_action(file, nil,
512
+ add: action_obj,
513
+ add_tag: add_tags,
514
+ all: true,
515
+ append: append,
516
+ delete: options[:delete],
517
+ done: options[:done],
518
+ edit: false, # Already handled above
519
+ finish: options[:finish],
520
+ move: target_proj,
521
+ note: note_val,
522
+ overwrite: options[:overwrite],
523
+ priority: priority,
524
+ project: options[:project],
525
+ remove_tag: remove_tags,
526
+ replace: options[:replace],
527
+ search_note: options[:search_notes],
528
+ tagged: nil)
529
+ end
425
530
  did_direct_update = true
426
531
  end
427
532
  if did_direct_update
@@ -539,8 +644,15 @@ class App
539
644
 
540
645
  NA.notify("#{NA.theme[:error]}No search terms provided", exit_code: 1) if tokens.nil? && options[:tagged].empty?
541
646
 
647
+ # Handle target_line if provided (from PATH:LINE format)
648
+ search_tokens = if options[:target_line]
649
+ { target_line: options[:target_line] }
650
+ else
651
+ tokens
652
+ end
653
+
542
654
  targets.each do |target|
543
- NA.update_action(target, tokens,
655
+ NA.update_action(target, search_tokens,
544
656
  add_tag: add_tags,
545
657
  all: options[:all],
546
658
  append: append,
@@ -556,7 +668,10 @@ class App
556
668
  remove_tag: remove_tags,
557
669
  replace: options[:replace],
558
670
  search_note: options[:search_notes],
559
- tagged: tags)
671
+ tagged: tags,
672
+ started_at: options[:started],
673
+ done_at: (options[:end] || options[:finished]),
674
+ duration_seconds: options[:duration])
560
675
  end
561
676
  end
562
677
  end
data/bin/na CHANGED
@@ -195,6 +195,12 @@ class App
195
195
  true
196
196
  end
197
197
  end
198
+
199
+ # Register custom GLI types for natural language dates and durations
200
+ # Return original string if parsing fails so commands can handle fallback parsing
201
+ accept(:date_begin) { |v| NA::Types.parse_date_begin(v) || v }
202
+ accept(:date_end) { |v| NA::Types.parse_date_end(v) || v }
203
+ accept(:duration) { |v| NA::Types.parse_duration_seconds(v) }
198
204
  end
199
205
 
200
206
  NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?