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.
data/lib/na/action.rb CHANGED
@@ -14,7 +14,12 @@ module NA
14
14
  def initialize(file, project, parent, action, idx, note = [])
15
15
  super()
16
16
 
17
- @file = file
17
+ # Store file in PATH:LINE format if line is available
18
+ @file = if idx.is_a?(Integer)
19
+ "#{file}:#{idx}"
20
+ else
21
+ file
22
+ end
18
23
  @project = project
19
24
  @parent = parent
20
25
  @action = action.gsub('{', '\\{')
@@ -23,6 +28,32 @@ module NA
23
28
  @note = note
24
29
  end
25
30
 
31
+ # Extract file path and line number from PATH:LINE format
32
+ #
33
+ # @return [Array] [file_path, line_number]
34
+ def file_line_parts
35
+ if @file.to_s.include?(':')
36
+ path, line = @file.split(':', 2)
37
+ [path, line.to_i]
38
+ else
39
+ [@file, @line]
40
+ end
41
+ end
42
+
43
+ # Get just the file path without line number
44
+ #
45
+ # @return [String] File path
46
+ def file_path
47
+ file_line_parts.first
48
+ end
49
+
50
+ # Get the line number
51
+ #
52
+ # @return [Integer] Line number
53
+ def file_line
54
+ file_line_parts.last
55
+ end
56
+
26
57
  # Update the action string and note with priority, tags, and completion status
27
58
  #
28
59
  # @param priority [Integer] Priority value to set
@@ -32,8 +63,8 @@ module NA
32
63
  # @param note [Array<String>] Notes to set
33
64
  # @return [void]
34
65
  # @example
35
- # action.process(priority: 5, finish: true, add_tag: ['urgent'], remove_tag: ['waiting'], note: ['Call Bob'])
36
- 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)
37
68
  string = @action.dup
38
69
 
39
70
  if priority&.positive?
@@ -53,9 +84,32 @@ module NA
53
84
  string += " @#{tag}"
54
85
  end
55
86
 
56
- string = "#{string.strip} @done(#{Time.now.strftime('%Y-%m-%d %H:%M')})" if finish && string !~ /(?<=\A| )@done/
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
57
110
 
58
111
  @action = string.expand_date_tags
112
+ @tags = scan_tags
59
113
  @note = note unless note.empty?
60
114
  end
61
115
 
@@ -70,7 +124,7 @@ module NA
70
124
  else
71
125
  ''
72
126
  end
73
- "(#{@file}:#{@line}) #{@project}:#{@parent.join('>')} | #{@action}#{note}"
127
+ "(#{file_path}:#{file_line}) #{@project}:#{@parent.join(">")} | #{@action}#{note}"
74
128
  end
75
129
 
76
130
  # Pretty string representation of the action with color formatting
@@ -82,7 +136,7 @@ module NA
82
136
  else
83
137
  ''
84
138
  end
85
- "{x}#{NA.theme[:filename]}#{File.basename(@file)}:#{@line}{x}#{NA.theme[:bracket]}[{x}#{NA.theme[:project]}#{@project}:#{@parent.join('>')}{x}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}{x}#{NA.theme[:note]}#{note}"
139
+ "{x}#{NA.theme[:filename]}#{File.basename(file_path)}:#{file_line}{x}#{NA.theme[:bracket]}[{x}#{NA.theme[:project]}#{@project}:#{@parent.join('>')}{x}#{NA.theme[:bracket]}]{x} | #{NA.theme[:action]}#{@action}{x}#{NA.theme[:note]}#{note}"
86
140
  end
87
141
 
88
142
  # Inspect the action object
@@ -112,49 +166,67 @@ module NA
112
166
  NA::Benchmark.measure('Action.pretty') do
113
167
  # Use cached theme instead of loading every time
114
168
  theme = NA.theme
115
- template = theme.merge(template)
169
+ # Merge templates if provided
170
+ if template[:templates]
171
+ theme = theme.dup
172
+ theme[:templates] = theme[:templates].merge(template[:templates])
173
+ template = theme.merge(template.reject { |k| k == :templates })
174
+ else
175
+ template = theme.merge(template)
176
+ end
116
177
 
117
178
  # Pre-compute common template parts to avoid repeated processing
118
179
  output_template = template[:templates][:output]
119
180
  needs_filename = output_template.include?('%filename')
181
+ needs_line = output_template.include?('%line')
120
182
  needs_parents = output_template.include?('%parents') || output_template.include?('%parent')
121
183
  needs_project = output_template.include?('%project')
122
184
 
123
185
  # Create the hierarchical parent string (optimized)
124
186
  parents = if needs_parents && @parent.any?
125
187
  parent_parts = @parent.map { |par| "#{template[:parent]}#{par}" }.join(template[:parent_divider])
126
- NA::Color.template("{x}#{template[:bracket]}[#{template[:error]}#{parent_parts}{x}#{template[:bracket]}]{x} ")
188
+ # parent_parts already has color codes embedded, create final string directly
189
+ "#{NA::Color.template("{x}#{template[:bracket]}[")}#{parent_parts}#{NA::Color.template("{x}#{template[:bracket]}]{x}")}"
127
190
  else
128
191
  ''
129
192
  end
130
193
 
131
- # Create the project string (optimized)
194
+ # Create the project string (optimized) - Ensure color reset before project
132
195
  project = if needs_project && !@project.empty?
133
- NA::Color.template("{x}#{template[:project]}#{@project}{x} ")
196
+ NA::Color.template("{x}#{template[:project]}#{@project}{x}")
134
197
  else
135
198
  ''
136
199
  end
137
200
 
138
201
  # Create the source filename string (optimized)
139
202
  filename = if needs_filename
140
- path = @file ? @file.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~') : ''
203
+ path_only = file_path # Extract just the path from PATH:LINE
204
+ path = path_only.sub(%r{^\./}, '').sub(/#{Dir.home}/, '~')
141
205
  if File.dirname(path) == '.'
142
206
  fname = NA.include_ext ? File.basename(path) : File.basename(path, ".#{extension}")
143
207
  fname = "./#{fname}" if NA.show_cwd_indicator
144
- NA::Color.template("#{template[:filename]}#{fname} {x}")
208
+ NA::Color.template("#{template[:filename]}#{fname}{x}")
145
209
  else
146
210
  colored = (NA.include_ext ? path : path.sub(/\.#{extension}$/, '')).highlight_filename
147
- NA::Color.template("#{template[:filename]}#{colored} {x}")
211
+ NA::Color.template("#{template[:filename]}#{colored}{x}")
148
212
  end
149
213
  else
150
214
  ''
151
215
  end
152
216
 
217
+ # Create the line number string (optimized)
218
+ line_num = if needs_line && @line
219
+ NA::Color.template("#{template[:line]}:#{@line} {x}")
220
+ else
221
+ ''
222
+ end
223
+
153
224
  # colorize the action and highlight tags (optimized)
154
225
  action_text = @action.dup
155
226
  action_text.gsub!(/\{(.*?)\}/, '\\{\1\\}')
156
227
  action_text = action_text.sub(/ @#{NA.na_tag}\b/, '')
157
- action = NA::Color.template("#{template[:action]}#{action_text}{x}")
228
+ # Reset colors before action to prevent bleeding from parents/project
229
+ action = NA::Color.template("{x}#{template[:action]}#{action_text}{x}")
158
230
  action = action.highlight_tags(color: template[:tags],
159
231
  parens: template[:value_parens],
160
232
  value: template[:values],
@@ -169,8 +241,8 @@ module NA
169
241
  width = @cached_width ||= TTY::Screen.columns
170
242
  # Calculate indent more efficiently - avoid repeated template processing
171
243
  base_template = output_template.gsub('%action', '').gsub('%note', '')
172
- base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/,
173
- parents)
244
+ base_output = base_template.gsub('%filename', filename).gsub('%line', line_num).gsub('%project', project).gsub(/%parents?/,
245
+ parents)
174
246
  indent = NA::Color.uncolor(NA::Color.template(base_output)).length
175
247
  note = NA::Color.template(@note.wrap(width, indent, template[:note]))
176
248
  else
@@ -185,7 +257,7 @@ module NA
185
257
  if detect_width && !action.empty?
186
258
  width = @cached_width ||= TTY::Screen.columns
187
259
  base_template = output_template.gsub('%action', '').gsub('%note', '')
188
- base_output = base_template.gsub('%filename', filename).gsub('%project', project).gsub(/%parents?/, parents)
260
+ base_output = base_template.gsub('%filename', filename).gsub('%line', line_num).gsub('%project', project).gsub(/%parents?/, parents)
189
261
  indent = NA::Color.uncolor(NA::Color.template(base_output)).length
190
262
  action = action.wrap(width, indent)
191
263
  end
@@ -193,6 +265,7 @@ module NA
193
265
  # Replace variables in template string and output colorized (optimized)
194
266
  final_output = output_template.dup
195
267
  final_output.gsub!('%filename', filename)
268
+ final_output.gsub!('%line', line_num)
196
269
  final_output.gsub!('%project', project)
197
270
  final_output.gsub!(/%parents?/, parents)
198
271
  final_output.gsub!('%action', action.highlight_search(regexes))
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
data/lib/na/editor.rb CHANGED
@@ -100,7 +100,12 @@ module NA
100
100
  tmpfile.unlink
101
101
  end
102
102
 
103
- input.split("\n").delete_if(&:ignore?).join("\n")
103
+ # Don't strip comments if this looks like multi-action format (has # ------ markers)
104
+ if input.include?('# ------ ')
105
+ input
106
+ else
107
+ input.split("\n").delete_if(&:ignore?).join("\n")
108
+ end
104
109
  end
105
110
 
106
111
  # Takes a multi-line string and formats it as an entry
@@ -129,6 +134,80 @@ module NA
129
134
 
130
135
  [title, note]
131
136
  end
137
+
138
+ # Format multiple actions for multi-edit
139
+ # @param actions [Array<Action>] Actions to edit
140
+ # @return [String] Formatted editor content
141
+ def format_multi_action_input(actions)
142
+ header = <<~EOF
143
+ # Instructions:
144
+ # - Edit the action text (the lines WITHOUT # comment markers)
145
+ # - DO NOT remove or edit the lines starting with "# ------"
146
+ # - Add notes on new lines after the action
147
+ # - Blank lines are ignored
148
+ #
149
+
150
+ EOF
151
+
152
+ # Use + to create a mutable string
153
+ content = +header
154
+
155
+ actions.each do |action|
156
+ # Use file_path to get the path and file_line to get the line number
157
+ content << "# ------ #{action.file_path}:#{action.file_line}\n"
158
+ content << "#{action.action}\n"
159
+ content << "#{action.note.join("\n")}\n" if action.note.any?
160
+ content << "\n" # Blank line separator
161
+ end
162
+
163
+ content
164
+ end
165
+
166
+ # Parse multi-action editor output
167
+ # @param content [String] Editor output
168
+ # @return [Hash] Hash mapping file:line to [action, note]
169
+ def parse_multi_action_output(content)
170
+ results = {}
171
+ current_file = nil
172
+ current_action = nil
173
+ current_note = []
174
+
175
+ content.lines.each do |line|
176
+ stripped = line.strip
177
+
178
+ # Check for file marker: # ------ path:line
179
+ match = stripped.match(/^# ------ (.+?):(\d+)$/)
180
+ if match
181
+ # Save previous action if exists
182
+ results[current_file] = [current_action, current_note] if current_file && current_action
183
+
184
+ # Start new action
185
+ current_file = "#{match[1]}:#{match[2]}"
186
+ current_action = nil
187
+ current_note = []
188
+ next
189
+ end
190
+
191
+ # Skip other comment lines
192
+ next if stripped.start_with?('#')
193
+
194
+ # Skip blank lines
195
+ next if stripped.empty?
196
+
197
+ # Store as action or note based on what we've seen so far
198
+ if current_action.nil?
199
+ current_action = stripped
200
+ else
201
+ # Subsequent lines are notes
202
+ current_note << stripped
203
+ end
204
+ end
205
+
206
+ # Save last action
207
+ results[current_file] = [current_action, current_note] if current_file && current_action
208
+
209
+ results
210
+ end
132
211
  end
133
212
  end
134
213
  end