na 1.2.86 → 1.2.88

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.
@@ -9,6 +9,32 @@ 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 'Run a plugin by name on selected actions'
13
+ c.arg_name 'NAME'
14
+ c.flag %i[plugin]
15
+
16
+ c.desc 'Plugin input format (json|yaml|csv|text)'
17
+ c.arg_name 'TYPE'
18
+ c.flag %i[input]
19
+
20
+ c.desc 'Plugin output format (json|yaml|csv|text)'
21
+ c.arg_name 'TYPE'
22
+ c.flag %i[output]
23
+
24
+ c.desc 'Divider string for text IO'
25
+ c.arg_name 'STRING'
26
+ c.flag %i[divider]
27
+ c.desc 'Started time (natural language or ISO)'
28
+ c.arg_name 'DATE'
29
+ c.flag %i[started], type: :date_begin
30
+
31
+ c.desc 'End/Finished time (natural language or ISO)'
32
+ c.arg_name 'DATE'
33
+ c.flag %i[end finished], type: :date_end
34
+
35
+ c.desc 'Duration (e.g. 45m, 2h, 1d2h30m, or minutes)'
36
+ c.arg_name 'DURATION'
37
+ c.flag %i[duration], type: :duration
12
38
  c.example 'na update --remove na "An existing task"',
13
39
  desc: 'Find "An existing task" action and remove the @na tag from it'
14
40
  c.example 'na update --tag waiting "A bug I need to fix" -p 4 -n',
@@ -299,7 +325,10 @@ class App
299
325
  options[:archive],
300
326
  options[:restore],
301
327
  options[:delete],
302
- options[:edit]
328
+ options[:edit],
329
+ options[:started],
330
+ (options[:end] || options[:finished]),
331
+ options[:duration]
303
332
  ].any?
304
333
  unless actionable
305
334
  # Interactive menu for actions
@@ -315,6 +344,17 @@ class App
315
344
  { key: :archive, label: 'Archive', param: nil },
316
345
  { key: :note, label: 'Add Note', param: 'Note' }
317
346
  ]
347
+ # Append available plugins
348
+ begin
349
+ NA::Plugins.ensure_plugins_home
350
+ NA::Plugins.list_plugins.each do |_key, path|
351
+ meta = NA::Plugins.parse_plugin_metadata(path)
352
+ disp = meta['name'] || File.basename(path, File.extname(path))
353
+ actions_menu << { key: :_plugin, label: "Plugin: #{disp}", param: nil, plugin_path: path }
354
+ end
355
+ rescue StandardError
356
+ # ignore plugin discovery errors in menu
357
+ end
318
358
  selector = nil
319
359
  if TTY::Which.exist?('fzf')
320
360
  selector = 'fzf --prompt="Select action> "'
@@ -361,6 +401,21 @@ class App
361
401
  options[:delete] = true
362
402
  when :finish
363
403
  options[:finish] = true
404
+ # Timed finish? Prompt user for optional start/date inputs
405
+ if NA.yn(NA::Color.template("#{NA.theme[:prompt]}Timed?"), default: false)
406
+ # Ask for start date expression
407
+ start_expr = nil
408
+ if TTY::Which.exist?('gum')
409
+ gum = TTY::Which.which('gum')
410
+ prompt = 'Enter start date/time (e.g. "30 minutes ago" or "3pm"):'
411
+ start_expr = `#{gum} input --placeholder "#{prompt}"`.strip
412
+ else
413
+ print 'Enter start date/time (e.g. "30 minutes ago" or "3pm"): '
414
+ start_expr = (STDIN.gets || '').strip
415
+ end
416
+ start_time = NA::Types.parse_date_begin(start_expr)
417
+ options[:started] = start_time if start_time
418
+ end
364
419
  when :edit
365
420
  # Just set the flag - multi-action editor will handle it below
366
421
  options[:edit] = true
@@ -412,6 +467,9 @@ class App
412
467
  when :note
413
468
  options[:note] = true
414
469
  note = [param_value]
470
+ when :_plugin
471
+ # Set plugin path directly
472
+ options[:plugin] = action_obj[:plugin_path]
415
473
  end
416
474
  end
417
475
  did_direct_update = false
@@ -424,7 +482,31 @@ class App
424
482
  actions_by_file[file] << targets_for_selection[idx][:action]
425
483
  end
426
484
 
427
- # Process each file's actions
485
+ # If a plugin is specified, run it on all selected actions and apply results
486
+ if options[:plugin]
487
+ plugin_path = options[:plugin]
488
+ unless File.exist?(plugin_path)
489
+ # Resolve by name via registry
490
+ resolved = NA::Plugins.resolve_plugin(plugin_path)
491
+ plugin_path = resolved if resolved
492
+ end
493
+ meta = NA::Plugins.parse_plugin_metadata(plugin_path)
494
+ input_fmt = (options[:input] || meta['input'] || 'json').to_s
495
+ output_fmt = (options[:output] || meta['output'] || input_fmt).to_s
496
+ divider = (options[:divider] || '||')
497
+
498
+ all_actions = []
499
+ actions_by_file.each_value { |list| all_actions.concat(list) }
500
+ io_actions = all_actions.map(&:to_plugin_io_hash)
501
+ stdin_str = NA::Plugins.serialize_actions(io_actions, format: input_fmt, divider: divider)
502
+ stdout = NA::Plugins.run_plugin(plugin_path, stdin_str)
503
+ returned = NA::Plugins.parse_actions(stdout, format: output_fmt, divider: divider)
504
+ Array(returned).each { |h| NA.apply_plugin_result(h) }
505
+ did_direct_update = true
506
+ next
507
+ end
508
+
509
+ # Process each file's actions (non-plugin paths)
428
510
  actions_by_file.each do |file, action_list|
429
511
  # Rebuild all derived variables from options after menu-driven assignment
430
512
  add_tags = options[:tag] ? options[:tag].join(',').split(/ *, */).map { |t| t.sub(/^@/, '') } : []
@@ -477,8 +559,8 @@ class App
477
559
  end
478
560
  end
479
561
 
480
- # Update each action
481
- action_list.each do |action_obj|
562
+ # Update each action (process from bottom to top to avoid line shifts)
563
+ action_list.sort_by(&:file_line).reverse.each do |action_obj|
482
564
  NA.update_action(file, nil,
483
565
  add: action_obj,
484
566
  add_tag: add_tags,
@@ -639,7 +721,10 @@ class App
639
721
  remove_tag: remove_tags,
640
722
  replace: options[:replace],
641
723
  search_note: options[:search_notes],
642
- tagged: tags)
724
+ tagged: tags,
725
+ started_at: options[:started],
726
+ done_at: (options[:end] || options[:finished]),
727
+ duration_seconds: options[:duration])
643
728
  end
644
729
  end
645
730
  end
data/bin/na CHANGED
@@ -113,6 +113,7 @@ class App
113
113
 
114
114
  pre do |global, _command, _options, _args|
115
115
  NA.move_deprecated_backups
116
+ NA::Plugins.ensure_plugins_home
116
117
  NA.verbose = global[:debug]
117
118
  NA::Pager.paginate = global[:pager] && $stdout.isatty
118
119
  NA::Color.coloring = global[:color] && $stdout.isatty
@@ -195,6 +196,12 @@ class App
195
196
  true
196
197
  end
197
198
  end
199
+
200
+ # Register custom GLI types for natural language dates and durations
201
+ # Return original string if parsing fails so commands can handle fallback parsing
202
+ accept(:date_begin) { |v| NA::Types.parse_date_begin(v) || v }
203
+ accept(:date_end) { |v| NA::Types.parse_date_end(v) || v }
204
+ accept(:duration) { |v| NA::Types.parse_duration_seconds(v) }
198
205
  end
199
206
 
200
207
  NA.stdin = $stdin.read.strip if $stdin.stat.size.positive? || $stdin.fcntl(Fcntl::F_GETFL, 0).zero?
data/lib/na/action.rb CHANGED
@@ -28,6 +28,19 @@ module NA
28
28
  @note = note
29
29
  end
30
30
 
31
+ # Convert action to plugin IO hash
32
+ # @return [Hash]
33
+ def to_plugin_io_hash
34
+ {
35
+ 'file_path' => file_path,
36
+ 'line' => file_line,
37
+ 'parents' => [@project].concat(@parent),
38
+ 'text' => @action.dup,
39
+ 'note' => @note.join("\n"),
40
+ 'tags' => @tags.map { |k, v| { 'name' => k, 'value' => (v || '').to_s } }
41
+ }
42
+ end
43
+
31
44
  # Extract file path and line number from PATH:LINE format
32
45
  #
33
46
  # @return [Array] [file_path, line_number]
@@ -63,8 +76,8 @@ module NA
63
76
  # @param note [Array<String>] Notes to set
64
77
  # @return [void]
65
78
  # @example
66
- # action.process(priority: 5, finish: true, add_tag: ['urgent'], remove_tag: ['waiting'], note: ['Call Bob'])
67
- def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
79
+ # action.process(priority: 5, finish: true, add_tag: ["urgent"], remove_tag: ["waiting"], note: ["Call Bob"], started_at: Time.now, done_at: Time.now)
80
+ def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [], started_at: nil, done_at: nil, duration_seconds: nil)
68
81
  string = @action.dup
69
82
 
70
83
  if priority&.positive?
@@ -84,9 +97,32 @@ module NA
84
97
  string += " @#{tag}"
85
98
  end
86
99
 
87
- string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /(?<=\A| )@done/
100
+ # Compute started/done from duration if provided
101
+ if duration_seconds && (done_at || finish)
102
+ done_time = done_at || Time.now
103
+ started_at ||= done_time - duration_seconds.to_i
104
+ elsif duration_seconds && started_at
105
+ done_at ||= started_at + duration_seconds.to_i
106
+ end
107
+
108
+ # Insert @started if provided
109
+ if started_at
110
+ string.gsub!(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '')
111
+ string.strip!
112
+ string += " @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
113
+ end
114
+
115
+ # Insert @done if provided or finishing
116
+ if done_at
117
+ string.gsub!(/(?<=\A| )@done\(.*?\)/i, '')
118
+ string.strip!
119
+ string += " @done(#{done_at.strftime('%Y-%m-%d %H:%M')})"
120
+ elsif finish && string !~ /(?<=\A| )@done/
121
+ string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})"
122
+ end
88
123
 
89
124
  @action = string.expand_date_tags
125
+ @tags = scan_tags
90
126
  @note = note unless note.empty?
91
127
  end
92
128
 
data/lib/na/actions.rb CHANGED
@@ -26,12 +26,26 @@ module NA
26
26
  notes: false,
27
27
  nest: false,
28
28
  nest_projects: false,
29
- no_files: false
29
+ no_files: false,
30
+ times: false,
31
+ human: false,
32
+ only_timed: false,
33
+ json_times: false
30
34
  }
31
35
  config = defaults.merge(config)
32
36
 
33
37
  return if config[:files].nil?
34
38
 
39
+ # Optionally filter to only actions with a computable duration (@started and @done)
40
+ filtered_actions = if config[:only_timed]
41
+ self.select do |a|
42
+ t = a.tags
43
+ (t['started'] || t['start']) && t['done']
44
+ end
45
+ else
46
+ self
47
+ end
48
+
35
49
  if config[:nest]
36
50
  template = NA.theme[:templates][:default]
37
51
  template = NA.theme[:templates][:no_file] if config[:no_files]
@@ -40,7 +54,7 @@ module NA
40
54
  out = []
41
55
 
42
56
  if config[:nest_projects]
43
- each do |action|
57
+ filtered_actions.each do |action|
44
58
  parent_files[action.file] ||= []
45
59
  parent_files[action.file].push(action)
46
60
  end
@@ -54,7 +68,7 @@ module NA
54
68
  template = NA.theme[:templates][:default]
55
69
  template = NA.theme[:templates][:no_file] if config[:no_files]
56
70
 
57
- each do |action|
71
+ filtered_actions.each do |action|
58
72
  parent_files[action.file] ||= []
59
73
  parent_files[action.file].push(action)
60
74
  end
@@ -94,14 +108,71 @@ module NA
94
108
 
95
109
  # Optimize output generation - compile all output first, then apply regexes
96
110
  output = String.new
111
+ total_seconds = 0
112
+ totals_by_tag = Hash.new(0)
113
+ timed_items = []
97
114
  NA::Benchmark.measure('Generate action strings') do
98
- each_with_index do |action, idx|
115
+ filtered_actions.each_with_index do |action, idx|
99
116
  # Generate raw output without regex processing
100
- output << action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
101
- output << "\n" unless idx == size - 1
117
+ line = action.pretty(template: { templates: { output: template } }, regexes: [], notes: config[:notes])
118
+
119
+ if config[:times]
120
+ # compute duration from @started/@done
121
+ tags = action.tags
122
+ begun = tags['started'] || tags['start']
123
+ finished = tags['done']
124
+ if begun && finished
125
+ begin
126
+ start_t = Time.parse(begun)
127
+ end_t = Time.parse(finished)
128
+ secs = [end_t - start_t, 0].max.to_i
129
+ total_seconds += secs
130
+ dur_color = NA.theme[:duration] || '{y}'
131
+ line << NA::Color.template(" #{dur_color}[#{format_duration(secs, human: config[:human])}]{x}")
132
+
133
+ # collect for JSON output
134
+ timed_items << {
135
+ action: NA::Color.uncolor(action.action),
136
+ started: start_t.iso8601,
137
+ ended: end_t.iso8601,
138
+ duration: secs
139
+ }
140
+
141
+ # accumulate per-tag durations (exclude time-control tags)
142
+ tags.each_key do |k|
143
+ next if k =~ /^(start|started|done)$/i
144
+
145
+ totals_by_tag[k.sub(/^@/, '')] += secs
146
+ end
147
+ rescue StandardError
148
+ # ignore parse errors
149
+ end
150
+ end
151
+ end
152
+
153
+ unless config[:only_times]
154
+ output << line
155
+ output << "\n" unless idx == filtered_actions.size - 1
156
+ end
102
157
  end
103
158
  end
104
159
 
160
+ # If JSON output requested, emit JSON and return immediately
161
+ if config[:json_times]
162
+ require 'json'
163
+ json = {
164
+ timed: timed_items,
165
+ tags: totals_by_tag.map { |k, v| { tag: k, duration: v } }.sort_by { |h| -h[:duration] },
166
+ total: {
167
+ seconds: total_seconds,
168
+ timestamp: format_duration(total_seconds, human: false),
169
+ human: format_duration(total_seconds, human: true)
170
+ }
171
+ }
172
+ puts JSON.pretty_generate(json)
173
+ return
174
+ end
175
+
105
176
  # Apply regex highlighting to the entire output at once
106
177
  if config[:regexes].any?
107
178
  NA::Benchmark.measure('Apply regex highlighting') do
@@ -109,11 +180,70 @@ module NA
109
180
  end
110
181
  end
111
182
 
183
+ if config[:times] && total_seconds.positive?
184
+ # Build Markdown table of per-tag totals
185
+ if totals_by_tag.empty?
186
+ # No tag totals, just show total line
187
+ dur_color = NA.theme[:duration] || '{y}'
188
+ output << "\n"
189
+ output << NA::Color.template("{x}#{dur_color}Total time: [#{format_duration(total_seconds, human: config[:human])}]{x}")
190
+ else
191
+ rows = totals_by_tag.sort_by { |_, v| -v }.map do |tag, secs|
192
+ disp = format_duration(secs, human: config[:human])
193
+ ["@#{tag}", disp]
194
+ end
195
+ # Pre-compute total display for width calculation
196
+ total_disp = format_duration(total_seconds, human: config[:human])
197
+ # Determine column widths, including footer labels/values
198
+ tag_header = 'Tag'
199
+ dur_header = config[:human] ? 'Duration (human)' : 'Duration'
200
+ tag_width = ([tag_header.length, 'Total'.length] + rows.map { |r| r[0].length }).max
201
+ dur_width = ([dur_header.length, total_disp.length] + rows.map { |r| r[1].length }).max
202
+
203
+ # Header
204
+ output << "\n"
205
+ output << "| #{tag_header.ljust(tag_width)} | #{dur_header.ljust(dur_width)} |\n"
206
+ # Separator for header
207
+ output << "| #{'-' * tag_width} | #{'-' * dur_width} |\n"
208
+ # Body rows
209
+ rows.each do |tag, disp|
210
+ output << "| #{tag.ljust(tag_width)} | #{disp.ljust(dur_width)} |\n"
211
+ end
212
+ # Footer separator (kramdown footer separator with '=') and footer row
213
+ output << "| #{'=' * tag_width} | #{'=' * dur_width} |\n"
214
+ output << "| #{'Total'.ljust(tag_width)} | #{total_disp.ljust(dur_width)} |\n"
215
+ end
216
+ end
217
+
112
218
  NA::Benchmark.measure('Pager.page call') do
113
219
  NA::Pager.page(output)
114
220
  end
115
221
  end
116
222
  end
117
223
  end
224
+
225
+ private
226
+
227
+ def format_duration(secs, human: false)
228
+ return '' if secs.nil?
229
+
230
+ secs = secs.to_i
231
+ days = secs / 86_400
232
+ rem = secs % 86_400
233
+ hours = rem / 3600
234
+ rem %= 3600
235
+ minutes = rem / 60
236
+ seconds = rem % 60
237
+ if human
238
+ parts = []
239
+ parts << "#{days} days" if days.positive?
240
+ parts << "#{hours} hours" if hours.positive?
241
+ parts << "#{minutes} minutes" if minutes.positive?
242
+ parts << "#{seconds} seconds" if seconds.positive? || parts.empty?
243
+ parts.join(', ')
244
+ else
245
+ format('%<d>02d:%<h>02d:%<m>02d:%<s>02d', d: days, h: hours, m: minutes, s: seconds)
246
+ end
247
+ end
118
248
  end
119
249
  end