na 1.2.86 → 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.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +4 -0
- data/.rubocop_todo.yml +22 -17
- data/CHANGELOG.md +58 -1
- data/Gemfile +7 -1
- data/Gemfile.lock +37 -1
- data/README.md +66 -2
- data/Rakefile +78 -78
- data/bin/commands/add.rb +31 -1
- data/bin/commands/changes.rb +1 -0
- data/bin/commands/complete.rb +11 -0
- data/bin/commands/find.rb +9 -1
- data/bin/commands/next.rb +35 -2
- data/bin/commands/tagged.rb +91 -58
- data/bin/commands/update.rb +36 -4
- data/bin/na +6 -0
- data/lib/na/action.rb +26 -3
- data/lib/na/actions.rb +136 -6
- data/lib/na/next_action.rb +88 -31
- data/lib/na/string.rb +9 -2
- data/lib/na/theme.rb +1 -0
- data/lib/na/types.rb +190 -0
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/src/_README.md +44 -1
- metadata +3 -1
data/bin/commands/tagged.rb
CHANGED
|
@@ -2,124 +2,152 @@
|
|
|
2
2
|
|
|
3
3
|
class App
|
|
4
4
|
extend GLI::App
|
|
5
|
-
desc
|
|
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
|
|
12
|
+
arg_name 'TAG[=VALUE]'
|
|
13
13
|
command %i[tagged] do |c|
|
|
14
|
-
c.example
|
|
15
|
-
c.example 'na tagged -d 3 "feature, idea"', desc:
|
|
16
|
-
c.example 'na tagged --or "feature, idea"', desc:
|
|
17
|
-
c.example 'na tagged "priority>=4"', desc:
|
|
18
|
-
c.example 'na tagged "due<in 2 days"', desc:
|
|
19
|
-
|
|
20
|
-
c.desc
|
|
21
|
-
c.arg_name
|
|
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
|
|
26
|
-
c.arg_name
|
|
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
|
|
29
|
+
c.desc 'Include notes in output'
|
|
30
30
|
c.switch %i[notes], negatable: true, default_value: false
|
|
31
31
|
|
|
32
|
-
c.desc
|
|
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
|
|
36
|
-
c.arg_name
|
|
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
|
|
40
|
-
c.arg_name
|
|
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
|
|
58
|
+
c.desc 'Include notes in search'
|
|
44
59
|
c.switch %i[search_notes], negatable: true, default_value: true
|
|
45
60
|
|
|
46
|
-
c.desc
|
|
61
|
+
c.desc 'Search query is regular expression'
|
|
47
62
|
c.switch %i[regex], negatable: false
|
|
48
63
|
|
|
49
|
-
c.desc
|
|
64
|
+
c.desc 'Search query is exact text match (not tokens)'
|
|
50
65
|
c.switch %i[exact], negatable: false
|
|
51
66
|
|
|
52
|
-
c.desc
|
|
67
|
+
c.desc 'Include @done actions'
|
|
53
68
|
c.switch %i[done]
|
|
54
69
|
|
|
55
|
-
c.desc
|
|
70
|
+
c.desc 'Show actions not matching tags'
|
|
56
71
|
c.switch %i[v invert], negatable: false
|
|
57
72
|
|
|
58
|
-
c.desc
|
|
59
|
-
c.arg_name
|
|
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
|
|
77
|
+
c.desc 'Output actions nested by file'
|
|
63
78
|
c.switch %i[nest], negatable: false
|
|
64
79
|
|
|
65
|
-
c.desc
|
|
80
|
+
c.desc 'No filename in output'
|
|
66
81
|
c.switch %i[no_file], negatable: false
|
|
67
82
|
|
|
68
|
-
c.desc
|
|
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]/,
|
|
76
|
-
cmd = NA.command_line.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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
89
|
-
args.join(
|
|
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[
|
|
95
|
-
comp: m[
|
|
96
|
-
value: m[
|
|
97
|
-
required: all_req || (!m[
|
|
98
|
-
negate: !m[
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
140
|
+
tokens = Regexp.new(options[:search].join(' '), Regexp::IGNORECASE)
|
|
113
141
|
else
|
|
114
142
|
tokens = []
|
|
115
|
-
all_req = options[:search].join(
|
|
143
|
+
all_req = options[:search].join(' ') !~ /(?<=[, ])[+!-]/ && !options[:or]
|
|
116
144
|
|
|
117
|
-
options[:search].join(
|
|
145
|
+
options[:search].join(' ').split(/ /).each do |arg|
|
|
118
146
|
m = arg.match(/^(?<req>[+\-!])?(?<tok>.*?)$/)
|
|
119
147
|
tokens.push({
|
|
120
|
-
token: m[
|
|
121
|
-
required: all_req || (!m[
|
|
122
|
-
negate: !m[
|
|
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[
|
|
136
|
-
required: all_req || (!m[
|
|
137
|
-
negate: !m[
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
data/bin/commands/update.rb
CHANGED
|
@@ -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',
|
|
@@ -299,7 +310,10 @@ class App
|
|
|
299
310
|
options[:archive],
|
|
300
311
|
options[:restore],
|
|
301
312
|
options[:delete],
|
|
302
|
-
options[:edit]
|
|
313
|
+
options[:edit],
|
|
314
|
+
options[:started],
|
|
315
|
+
(options[:end] || options[:finished]),
|
|
316
|
+
options[:duration]
|
|
303
317
|
].any?
|
|
304
318
|
unless actionable
|
|
305
319
|
# Interactive menu for actions
|
|
@@ -361,6 +375,21 @@ class App
|
|
|
361
375
|
options[:delete] = true
|
|
362
376
|
when :finish
|
|
363
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
|
|
364
393
|
when :edit
|
|
365
394
|
# Just set the flag - multi-action editor will handle it below
|
|
366
395
|
options[:edit] = true
|
|
@@ -477,8 +506,8 @@ class App
|
|
|
477
506
|
end
|
|
478
507
|
end
|
|
479
508
|
|
|
480
|
-
# Update each action
|
|
481
|
-
action_list.each do |action_obj|
|
|
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|
|
|
482
511
|
NA.update_action(file, nil,
|
|
483
512
|
add: action_obj,
|
|
484
513
|
add_tag: add_tags,
|
|
@@ -639,7 +668,10 @@ class App
|
|
|
639
668
|
remove_tag: remove_tags,
|
|
640
669
|
replace: options[:replace],
|
|
641
670
|
search_note: options[:search_notes],
|
|
642
|
-
tagged: tags
|
|
671
|
+
tagged: tags,
|
|
672
|
+
started_at: options[:started],
|
|
673
|
+
done_at: (options[:end] || options[:finished]),
|
|
674
|
+
duration_seconds: options[:duration])
|
|
643
675
|
end
|
|
644
676
|
end
|
|
645
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?
|
data/lib/na/action.rb
CHANGED
|
@@ -63,8 +63,8 @@ module NA
|
|
|
63
63
|
# @param note [Array<String>] Notes to set
|
|
64
64
|
# @return [void]
|
|
65
65
|
# @example
|
|
66
|
-
# action.process(priority: 5, finish: true, add_tag: [
|
|
67
|
-
def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [])
|
|
66
|
+
# action.process(priority: 5, finish: true, add_tag: ["urgent"], remove_tag: ["waiting"], note: ["Call Bob"], started_at: Time.now, done_at: Time.now)
|
|
67
|
+
def process(priority: 0, finish: false, add_tag: [], remove_tag: [], note: [], started_at: nil, done_at: nil, duration_seconds: nil)
|
|
68
68
|
string = @action.dup
|
|
69
69
|
|
|
70
70
|
if priority&.positive?
|
|
@@ -84,9 +84,32 @@ module NA
|
|
|
84
84
|
string += " @#{tag}"
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
# Compute started/done from duration if provided
|
|
88
|
+
if duration_seconds && (done_at || finish)
|
|
89
|
+
done_time = done_at || Time.now
|
|
90
|
+
started_at ||= done_time - duration_seconds.to_i
|
|
91
|
+
elsif duration_seconds && started_at
|
|
92
|
+
done_at ||= started_at + duration_seconds.to_i
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Insert @started if provided
|
|
96
|
+
if started_at
|
|
97
|
+
string.gsub!(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '')
|
|
98
|
+
string.strip!
|
|
99
|
+
string += " @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Insert @done if provided or finishing
|
|
103
|
+
if done_at
|
|
104
|
+
string.gsub!(/(?<=\A| )@done\(.*?\)/i, '')
|
|
105
|
+
string.strip!
|
|
106
|
+
string += " @done(#{done_at.strftime('%Y-%m-%d %H:%M')})"
|
|
107
|
+
elsif finish && string !~ /(?<=\A| )@done/
|
|
108
|
+
string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})"
|
|
109
|
+
end
|
|
88
110
|
|
|
89
111
|
@action = string.expand_date_tags
|
|
112
|
+
@tags = scan_tags
|
|
90
113
|
@note = note unless note.empty?
|
|
91
114
|
end
|
|
92
115
|
|
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
|
-
|
|
101
|
-
|
|
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
|