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.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +4 -0
- data/.cursor/commands/priority35m36m335m32m.md +0 -0
- data/.rubocop_todo.yml +38 -33
- data/CHANGELOG.md +61 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +45 -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 +154 -39
- data/bin/na +6 -0
- data/lib/na/action.rb +90 -17
- data/lib/na/actions.rb +136 -6
- data/lib/na/editor.rb +80 -1
- data/lib/na/next_action.rb +136 -48
- data/lib/na/string.rb +16 -5
- data/lib/na/theme.rb +51 -41
- data/lib/na/types.rb +190 -0
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +12 -1
- data/src/_README.md +44 -1
- metadata +4 -1
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
"(#{
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/na/editor.rb
CHANGED
|
@@ -100,7 +100,12 @@ module NA
|
|
|
100
100
|
tmpfile.unlink
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
|
|
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
|